feat: add Coupang search and add-to-cart adapters
Add browser-backed Coupang adapters for search and add-to-cart workflows. - coupang search: multi-strategy data collection (API/JSON-LD/bootstrap/DOM), structured fields (price, rating, rocket, delivery), pagination and rocket filter support - coupang add-to-cart: logged-in browser session reuse, stops before checkout - coupang.ts: comprehensive data normalization layer with badge/rocket/delivery mapping - browser-tab.ts: withTemporaryTab utility for isolated tab operations - Unit tests for core normalization functions Co-authored-by: CodeBBakGoSu <127713112+CodeBBakGoSu@users.noreply.github.com>
This commit is contained in:
1 parent
9e024d4e46
commit
c77c8a8e3a
8 files changed
+1174
-4
No files matched your search
@@ -9,7 +9,7 @@
|
||||
[](https://nodejs.org)
|
||||
[](./LICENSE)
|
||||
|
||||
A CLI tool that turns **any website** into a command-line interface. **57 commands** across **17 sites** — bilibili, zhihu, xiaohongshu, twitter, reddit, xueqiu, github, v2ex, hackernews, bbc, weibo, boss, yahoo-finance, reuters, smzdm, ctrip, youtube — powered by browser session reuse and AI-native discovery.
|
||||
A CLI tool that turns **any website** into a command-line interface. **59 commands** across **18 sites** — bilibili, zhihu, xiaohongshu, twitter, reddit, xueqiu, github, v2ex, hackernews, bbc, weibo, boss, yahoo-finance, reuters, smzdm, ctrip, youtube, coupang — powered by browser session reuse and AI-native discovery.
|
||||
|
||||
---
|
||||
|
||||
@@ -126,6 +126,7 @@ npm install -g @jackwener/opencli@latest
|
||||
| **reddit** | `hot` `frontpage` `search` `subreddit` | 🔐 Browser |
|
||||
| **weibo** | `hot` | 🔐 Browser |
|
||||
| **boss** | `search` | 🔐 Browser |
|
||||
| **coupang** | `search` `add-to-cart` | 🔐 Browser |
|
||||
| **youtube** | `search` | 🔐 Browser |
|
||||
| **yahoo-finance** | `quote` | 🔐 Browser |
|
||||
| **reuters** | `search` | 🔐 Browser |
|
||||
|
||||
+3
-2
@@ -9,7 +9,7 @@
|
||||
[](https://nodejs.org)
|
||||
[](./LICENSE)
|
||||
|
||||
OpenCLI 将任何网站变成命令行工具。**57 个命令**覆盖 **17 个站点** — B站、知乎、小红书、Twitter、Reddit、雪球、GitHub、V2EX、Hacker News、BBC、微博、BOSS直聘、Yahoo Finance、路透社、什么值得买、携程、YouTube — 复用浏览器登录态,AI 驱动探索。
|
||||
OpenCLI 将任何网站变成命令行工具。**59 个命令**覆盖 **18 个站点** — B站、知乎、小红书、Twitter、Reddit、雪球、GitHub、V2EX、Hacker News、BBC、微博、BOSS直聘、Yahoo Finance、路透社、什么值得买、携程、YouTube、Coupang — 复用浏览器登录态,AI 驱动探索。
|
||||
|
||||
---
|
||||
|
||||
@@ -29,7 +29,7 @@ OpenCLI 将任何网站变成命令行工具。**57 个命令**覆盖 **17 个
|
||||
|
||||
## 亮点
|
||||
|
||||
- **57 个命令,17 个站点** — B站、知乎、小红书、Twitter、Reddit、雪球(xueqiu)、GitHub、V2EX、Hacker News、BBC、微博、BOSS直聘、Yahoo Finance、路透社、什么值得买、携程、YouTube
|
||||
- **59 个命令,18 个站点** — B站、知乎、小红书、Twitter、Reddit、雪球(xueqiu)、GitHub、V2EX、Hacker News、BBC、微博、BOSS直聘、Yahoo Finance、路透社、什么值得买、携程、YouTube、Coupang
|
||||
- **零风控** — 复用 Chrome 登录态,无需存储任何凭证
|
||||
- **AI 原生** — `explore` 自动发现 API,`synthesize` 生成适配器,`cascade` 探测认证策略
|
||||
- **动态加载引擎** — 声明式的 `.yaml` 或者底层定制的 `.ts` 适配器,放入 `clis/` 文件夹即可自动注册生效
|
||||
@@ -126,6 +126,7 @@ npm install -g @jackwener/opencli@latest
|
||||
| **reddit** | `hot` `frontpage` `search` `subreddit` | 🔐 浏览器 |
|
||||
| **weibo** | `hot` | 🔐 浏览器 |
|
||||
| **boss** | `search` | 🔐 浏览器 |
|
||||
| **coupang** | `search` `add-to-cart` | 🔐 浏览器 |
|
||||
| **youtube** | `search` | 🔐 浏览器 |
|
||||
| **yahoo-finance** | `quote` | 🔐 浏览器 |
|
||||
| **reuters** | `search` | 🔐 浏览器 |
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
import { cli, Strategy } from '../../registry.js';
|
||||
import { canonicalizeProductUrl, normalizeProductId } from '../../coupang.js';
|
||||
|
||||
function escapeJsString(value: string): string {
|
||||
return JSON.stringify(value);
|
||||
}
|
||||
|
||||
function buildAddToCartEvaluate(expectedProductId: string): string {
|
||||
return `
|
||||
(async () => {
|
||||
const expectedProductId = ${escapeJsString(expectedProductId)};
|
||||
const text = document.body.innerText || '';
|
||||
const loginHints = {
|
||||
hasLoginLink: Boolean(document.querySelector('a[href*="login"], a[title*="로그인"]')),
|
||||
hasMyCoupang: /마이쿠팡/.test(text),
|
||||
};
|
||||
|
||||
const pathMatch = location.pathname.match(/\\/vp\\/products\\/(\\d+)/);
|
||||
const currentProductId = pathMatch?.[1] || '';
|
||||
if (expectedProductId && currentProductId && expectedProductId !== currentProductId) {
|
||||
return { ok: false, reason: 'PRODUCT_MISMATCH', currentProductId, loginHints };
|
||||
}
|
||||
|
||||
const optionSelectors = [
|
||||
'select',
|
||||
'[role="listbox"]',
|
||||
'.prod-option, .product-option, .option-select, .option-dropdown',
|
||||
];
|
||||
const hasRequiredOption = optionSelectors.some((selector) => {
|
||||
try {
|
||||
const nodes = Array.from(document.querySelectorAll(selector));
|
||||
return nodes.some((node) => {
|
||||
const label = (node.textContent || '') + ' ' + (node.getAttribute?.('aria-label') || '');
|
||||
return /옵션|색상|사이즈|용량|선택/i.test(label);
|
||||
});
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
});
|
||||
if (hasRequiredOption) {
|
||||
return { ok: false, reason: 'OPTION_REQUIRED', currentProductId, loginHints };
|
||||
}
|
||||
|
||||
const clickCandidate = (elements) => {
|
||||
for (const element of elements) {
|
||||
if (!(element instanceof HTMLElement)) continue;
|
||||
const label = ((element.innerText || '') + ' ' + (element.getAttribute('aria-label') || '')).trim();
|
||||
if (/장바구니|카트|cart/i.test(label) && !/sold out|품절/i.test(label)) {
|
||||
element.click();
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
const beforeCount = (() => {
|
||||
const node = document.querySelector('[class*="cart"] .count, #headerCartCount, .cart-count');
|
||||
const text = node?.textContent || '';
|
||||
const num = Number(text.replace(/[^\\d]/g, ''));
|
||||
return Number.isFinite(num) ? num : null;
|
||||
})();
|
||||
|
||||
const buttons = Array.from(document.querySelectorAll('button, a[role="button"], input[type="button"]'));
|
||||
const clicked = clickCandidate(buttons);
|
||||
if (!clicked) {
|
||||
return { ok: false, reason: 'ADD_TO_CART_BUTTON_NOT_FOUND', currentProductId, loginHints };
|
||||
}
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 2500));
|
||||
|
||||
const afterText = document.body.innerText || '';
|
||||
const successMessage = /장바구니에 담|장바구니 담기 완료|added to cart/i.test(afterText);
|
||||
const afterCount = (() => {
|
||||
const node = document.querySelector('[class*="cart"] .count, #headerCartCount, .cart-count');
|
||||
const text = node?.textContent || '';
|
||||
const num = Number(text.replace(/[^\\d]/g, ''));
|
||||
return Number.isFinite(num) ? num : null;
|
||||
})();
|
||||
const countIncreased =
|
||||
beforeCount != null &&
|
||||
afterCount != null &&
|
||||
afterCount >= beforeCount &&
|
||||
(afterCount > beforeCount || beforeCount === 0);
|
||||
|
||||
return {
|
||||
ok: successMessage || countIncreased,
|
||||
reason: successMessage || countIncreased ? 'SUCCESS' : 'UNKNOWN',
|
||||
currentProductId,
|
||||
beforeCount,
|
||||
afterCount,
|
||||
loginHints,
|
||||
};
|
||||
})()
|
||||
`;
|
||||
}
|
||||
|
||||
cli({
|
||||
site: 'coupang',
|
||||
name: 'add-to-cart',
|
||||
description: 'Add a Coupang product to cart using logged-in browser session',
|
||||
domain: 'www.coupang.com',
|
||||
strategy: Strategy.COOKIE,
|
||||
browser: true,
|
||||
args: [
|
||||
{ name: 'productId', required: false, help: 'Coupang product ID' },
|
||||
{ name: 'url', required: false, help: 'Canonical product URL' },
|
||||
],
|
||||
columns: ['ok', 'product_id', 'url', 'message'],
|
||||
func: async (page, kwargs) => {
|
||||
const rawProductId = kwargs.productId ?? kwargs.product_id;
|
||||
const productId = normalizeProductId(rawProductId);
|
||||
const targetUrl = canonicalizeProductUrl(kwargs.url, productId);
|
||||
|
||||
if (!productId && !targetUrl) {
|
||||
throw new Error('Either --product-id or --url is required');
|
||||
}
|
||||
|
||||
const finalUrl = targetUrl || canonicalizeProductUrl('', productId);
|
||||
await page.goto(finalUrl);
|
||||
await page.wait(3);
|
||||
|
||||
const result = await page.evaluate(buildAddToCartEvaluate(productId));
|
||||
const loginHints = result?.loginHints ?? {};
|
||||
if (loginHints.hasLoginLink && !loginHints.hasMyCoupang) {
|
||||
throw new Error('Coupang login required. Please log into Coupang in Chrome and retry.');
|
||||
}
|
||||
|
||||
const actualProductId = normalizeProductId(result?.currentProductId || productId);
|
||||
if (result?.reason === 'PRODUCT_MISMATCH') {
|
||||
throw new Error(`Product mismatch: expected ${productId}, got ${actualProductId || 'unknown'}`);
|
||||
}
|
||||
if (result?.reason === 'OPTION_REQUIRED') {
|
||||
throw new Error('This product requires option selection and is not supported in v1.');
|
||||
}
|
||||
if (result?.reason === 'ADD_TO_CART_BUTTON_NOT_FOUND') {
|
||||
throw new Error('Could not find an add-to-cart button on the product page.');
|
||||
}
|
||||
if (!result?.ok) {
|
||||
throw new Error('Failed to confirm add-to-cart success.');
|
||||
}
|
||||
|
||||
return [{
|
||||
ok: true,
|
||||
product_id: actualProductId || productId,
|
||||
url: finalUrl,
|
||||
message: 'Added to cart',
|
||||
}];
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,466 @@
|
||||
import { cli, Strategy } from '../../registry.js';
|
||||
import { mergeSearchItems, normalizeSearchItem, sanitizeSearchItems } from '../../coupang.js';
|
||||
|
||||
function escapeJsString(value: string): string {
|
||||
return JSON.stringify(value);
|
||||
}
|
||||
|
||||
function buildApplyFilterEvaluate(filter: string): string {
|
||||
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(): string {
|
||||
return `
|
||||
() => ({
|
||||
href: location.href
|
||||
})
|
||||
`;
|
||||
}
|
||||
|
||||
function buildSearchEvaluate(query: string, limit: number, pageNumber: number): string {
|
||||
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',
|
||||
description: 'Search Coupang products with logged-in browser session',
|
||||
domain: 'www.coupang.com',
|
||||
strategy: Strategy.COOKIE,
|
||||
browser: true,
|
||||
args: [
|
||||
{ name: 'query', required: 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', 'title', 'price', 'unit_price', 'rating', 'review_count', 'rocket', 'delivery_type', 'delivery_promise', 'url'],
|
||||
func: async (page, kwargs) => {
|
||||
const query = String(kwargs.query || '').trim();
|
||||
const pageNumber = Math.max(Number(kwargs.page || 1), 1);
|
||||
const limit = Math.min(Math.max(Number(kwargs.limit || 20), 1), 50);
|
||||
const filter = String(kwargs.filter || '').trim().toLowerCase();
|
||||
if (!query) throw new Error('Query is required');
|
||||
|
||||
const initialPage = filter ? 1 : pageNumber;
|
||||
const url = `https://www.coupang.com/np/search?q=${encodeURIComponent(query)}&channel=user&page=${initialPage}`;
|
||||
await page.goto(url);
|
||||
await page.wait(3);
|
||||
if (filter) {
|
||||
const filterResult = await page.evaluate(buildApplyFilterEvaluate(filter));
|
||||
if (!filterResult?.ok) {
|
||||
throw new Error(`Unsupported or unavailable filter: ${filter}`);
|
||||
}
|
||||
await page.wait(3);
|
||||
if (pageNumber > 1) {
|
||||
const locationInfo = await page.evaluate(buildCurrentLocationEvaluate());
|
||||
const filteredUrl = new URL(locationInfo?.href || url);
|
||||
filteredUrl.searchParams.set('page', String(pageNumber));
|
||||
await page.goto(filteredUrl.toString());
|
||||
await page.wait(3);
|
||||
}
|
||||
}
|
||||
await page.autoScroll({ times: filter ? 3 : 2, delayMs: 1500 });
|
||||
|
||||
const raw = await page.evaluate(buildSearchEvaluate(query, limit, pageNumber));
|
||||
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: Record<string, unknown>, index: number) => normalizeSearchItem(item, index)),
|
||||
limit
|
||||
);
|
||||
const normalizedDom = sanitizeSearchItems(
|
||||
domItems.map((item: Record<string, unknown>, index: number) => normalizeSearchItem(item, index)),
|
||||
Math.max(limit * 6, 60)
|
||||
);
|
||||
const normalized = filter
|
||||
? sanitizeSearchItems(normalizedDom, limit)
|
||||
: mergeSearchItems(normalizedBase, normalizedDom, limit);
|
||||
|
||||
if (!normalized.length && loginHints.hasLoginLink && !loginHints.hasMyCoupang) {
|
||||
throw new Error('Coupang login required. Please log into Coupang in Chrome and retry.');
|
||||
}
|
||||
return normalized;
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,78 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
canonicalizeProductUrl,
|
||||
dedupeSearchItems,
|
||||
normalizeProductId,
|
||||
normalizeSearchItem,
|
||||
sanitizeSearchItems,
|
||||
} from './coupang.js';
|
||||
|
||||
describe('normalizeProductId', () => {
|
||||
it('extracts product id from canonical path', () => {
|
||||
expect(normalizeProductId('https://www.coupang.com/vp/products/123456789')).toBe('123456789');
|
||||
});
|
||||
|
||||
it('preserves numeric ids', () => {
|
||||
expect(normalizeProductId('987654321')).toBe('987654321');
|
||||
});
|
||||
});
|
||||
|
||||
describe('canonicalizeProductUrl', () => {
|
||||
it('normalizes relative Coupang paths', () => {
|
||||
expect(canonicalizeProductUrl('/vp/products/123456789?itemId=1', '')).toBe(
|
||||
'https://www.coupang.com/vp/products/123456789'
|
||||
);
|
||||
});
|
||||
|
||||
it('builds url from product id', () => {
|
||||
expect(canonicalizeProductUrl('', '123456789')).toBe('https://www.coupang.com/vp/products/123456789');
|
||||
});
|
||||
});
|
||||
|
||||
describe('normalizeSearchItem', () => {
|
||||
it('maps raw fields into compare-ready shape', () => {
|
||||
const item = normalizeSearchItem({
|
||||
productId: '123456789',
|
||||
productName: '무선 마우스',
|
||||
salePrice: '29,900원',
|
||||
originalPrice: '39,900원',
|
||||
rating: '4.8',
|
||||
reviewCount: '1,234',
|
||||
sellerName: '쿠팡',
|
||||
badge: ['ROCKET', 'TOMORROW', '무료배송'],
|
||||
categoryName: 'PC',
|
||||
url: '/vp/products/123456789?itemId=1',
|
||||
}, 0);
|
||||
|
||||
expect(item).toMatchObject({
|
||||
rank: 1,
|
||||
product_id: '123456789',
|
||||
title: '무선 마우스',
|
||||
price: 29900,
|
||||
original_price: 39900,
|
||||
rating: 4.8,
|
||||
review_count: 1234,
|
||||
rocket: '로켓배송',
|
||||
delivery_type: '무료배송',
|
||||
delivery_promise: '내일도착',
|
||||
seller: '쿠팡',
|
||||
category: 'PC',
|
||||
url: 'https://www.coupang.com/vp/products/123456789',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('sanitizeSearchItems', () => {
|
||||
it('drops duplicates and invalid rows', () => {
|
||||
const rows = [
|
||||
normalizeSearchItem({ productId: '1', productName: 'A', price: '1000', url: '/vp/products/1' }, 0),
|
||||
normalizeSearchItem({ productId: '1', productName: 'A', price: '1000', url: '/vp/products/1' }, 1),
|
||||
normalizeSearchItem({ productId: '', productName: '', price: '1000' }, 2),
|
||||
normalizeSearchItem({ productId: '2', productName: 'B', price: '2000', url: '/vp/products/2' }, 3),
|
||||
];
|
||||
|
||||
expect(dedupeSearchItems(rows)).toHaveLength(3);
|
||||
expect(sanitizeSearchItems(rows, 10)).toHaveLength(2);
|
||||
expect(sanitizeSearchItems(rows, 10).map(item => item.rank)).toEqual([1, 2]);
|
||||
});
|
||||
});
|
||||
+302
@@ -0,0 +1,302 @@
|
||||
export interface CoupangSearchItem {
|
||||
rank: number;
|
||||
product_id: string;
|
||||
title: string;
|
||||
price: number | null;
|
||||
original_price: number | null;
|
||||
unit_price: string;
|
||||
discount_rate: number | null;
|
||||
rating: number | null;
|
||||
review_count: number | null;
|
||||
rocket: string;
|
||||
delivery_type: string;
|
||||
delivery_promise: string;
|
||||
seller: string;
|
||||
badge: string;
|
||||
category: string;
|
||||
url: string;
|
||||
}
|
||||
|
||||
function itemKey(item: CoupangSearchItem): string {
|
||||
return item.url || item.product_id || `${item.title}:${item.price ?? ''}`;
|
||||
}
|
||||
|
||||
const ROCKET_PATTERNS = ['판매자로켓', '로켓프레시', '로켓와우', '로켓배송', '로켓직구'] as const;
|
||||
const DELIVERY_TYPE_PATTERNS = ['무료배송', '일반배송'] as const;
|
||||
const DELIVERY_PROMISE_PATTERNS = ['오늘도착', '내일도착', '새벽도착', '오늘출발'] as const;
|
||||
|
||||
const BADGE_ID_TO_ROCKET: Record<string, string> = {
|
||||
ROCKET: '로켓배송',
|
||||
ROCKET_MERCHANT: '판매자로켓',
|
||||
ROCKET_WOW: '로켓와우',
|
||||
WOW: '로켓와우',
|
||||
ROCKET_FRESH: '로켓프레시',
|
||||
FRESH: '로켓프레시',
|
||||
SELLER_ROCKET: '판매자로켓',
|
||||
ROCKET_JIKGU: '로켓직구',
|
||||
JIKGU: '로켓직구',
|
||||
COUPANG_GLOBAL: '로켓직구',
|
||||
};
|
||||
|
||||
const BADGE_ID_TO_PROMISE: Record<string, string> = {
|
||||
DAWN: '새벽도착',
|
||||
EARLY_DAWN: '새벽도착',
|
||||
TOMORROW: '내일도착',
|
||||
TODAY: '오늘도착',
|
||||
SAME_DAY: '오늘도착',
|
||||
TODAY_SHIP: '오늘출발',
|
||||
TODAY_DISPATCH: '오늘출발',
|
||||
};
|
||||
|
||||
function asString(value: unknown): string {
|
||||
if (value == null) return '';
|
||||
return String(value).trim();
|
||||
}
|
||||
|
||||
function toNumber(value: unknown): number | null {
|
||||
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: Record<string, unknown>, paths: string[]): unknown {
|
||||
for (const path of paths) {
|
||||
const parts = path.split('.');
|
||||
let current: unknown = obj;
|
||||
let ok = true;
|
||||
for (const part of parts) {
|
||||
if (!current || typeof current !== 'object' || !(part in (current as Record<string, unknown>))) {
|
||||
ok = false;
|
||||
break;
|
||||
}
|
||||
current = (current as Record<string, unknown>)[part];
|
||||
}
|
||||
if (ok && current != null && asString(current) !== '') return current;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function normalizeProductId(raw: unknown): string {
|
||||
const text = asString(raw);
|
||||
if (!text) return '';
|
||||
const match = text.match(/\/vp\/products\/(\d+)/) || text.match(/\b(\d{6,})\b/);
|
||||
return match?.[1] ?? text;
|
||||
}
|
||||
|
||||
export function canonicalizeProductUrl(rawUrl: unknown, productId?: unknown): string {
|
||||
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: unknown[]): string[] {
|
||||
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: unknown[]): string {
|
||||
return values
|
||||
.map(asString)
|
||||
.filter(Boolean)
|
||||
.join(' ')
|
||||
.replace(/schema\.org\/[A-Za-z]+/gi, ' ')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim();
|
||||
}
|
||||
|
||||
function normalizeRocket(...values: unknown[]): string {
|
||||
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: unknown[]): string {
|
||||
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: unknown[]): string {
|
||||
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: unknown): string {
|
||||
const normalizeOne = (entry: unknown): string => {
|
||||
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: Record<string, unknown>, index: number): CoupangSearchItem {
|
||||
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: CoupangSearchItem[]): CoupangSearchItem[] {
|
||||
const seen = new Set<string>();
|
||||
const out: CoupangSearchItem[] = [];
|
||||
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: CoupangSearchItem[], limit: number): CoupangSearchItem[] {
|
||||
return dedupeSearchItems(
|
||||
items.filter(item => Boolean(item.title && (item.product_id || item.url)))
|
||||
).slice(0, limit);
|
||||
}
|
||||
|
||||
export function mergeSearchItems(base: CoupangSearchItem[], extra: CoupangSearchItem[], limit: number): CoupangSearchItem[] {
|
||||
const extraMap = new Map<string, CoupangSearchItem>();
|
||||
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);
|
||||
}
|
||||
@@ -90,6 +90,8 @@ describe('doctor report rendering', () => {
|
||||
const text = renderBrowserDoctorReport({
|
||||
envToken: 'abc123',
|
||||
envFingerprint: 'fp1',
|
||||
extensionToken: 'abc123',
|
||||
extensionFingerprint: 'fp1',
|
||||
shellFiles: [{ path: '/tmp/.zshrc', exists: true, token: 'abc123', fingerprint: 'fp1' }],
|
||||
configs: [{ path: '/tmp/mcp.json', exists: true, format: 'json', token: 'abc123', fingerprint: 'fp1', writable: true }],
|
||||
recommendedToken: 'abc123',
|
||||
@@ -106,6 +108,8 @@ describe('doctor report rendering', () => {
|
||||
const text = renderBrowserDoctorReport({
|
||||
envToken: 'abc123',
|
||||
envFingerprint: 'fp1',
|
||||
extensionToken: null,
|
||||
extensionFingerprint: null,
|
||||
shellFiles: [{ path: '/tmp/.zshrc', exists: true, token: 'def456', fingerprint: 'fp2' }],
|
||||
configs: [{ path: '/tmp/mcp.json', exists: true, format: 'json', token: 'abc123', fingerprint: 'fp1', writable: true }],
|
||||
recommendedToken: 'abc123',
|
||||
|
||||
+170
-1
@@ -43,6 +43,8 @@ export type DoctorReport = {
|
||||
cliVersion?: string;
|
||||
envToken: string | null;
|
||||
envFingerprint: string | null;
|
||||
extensionToken: string | null;
|
||||
extensionFingerprint: string | null;
|
||||
shellFiles: ShellFileStatus[];
|
||||
configs: McpConfigStatus[];
|
||||
recommendedToken: string | null;
|
||||
@@ -220,6 +222,163 @@ function readConfigStatus(filePath: string): McpConfigStatus {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Discover the auth token stored by the Playwright MCP Bridge extension
|
||||
* by scanning Chrome's LevelDB localStorage files directly.
|
||||
*
|
||||
* Uses `strings` + `grep` for fast binary scanning on macOS/Linux,
|
||||
* with a pure-Node fallback on Windows.
|
||||
*/
|
||||
export function discoverExtensionToken(): string | null {
|
||||
const home = os.homedir();
|
||||
const platform = os.platform();
|
||||
const bases: string[] = [];
|
||||
|
||||
if (platform === 'darwin') {
|
||||
bases.push(
|
||||
path.join(home, 'Library', 'Application Support', 'Google', 'Chrome'),
|
||||
path.join(home, 'Library', 'Application Support', 'Google', 'Chrome Canary'),
|
||||
path.join(home, 'Library', 'Application Support', 'Chromium'),
|
||||
path.join(home, 'Library', 'Application Support', 'Microsoft Edge'),
|
||||
);
|
||||
} else if (platform === 'linux') {
|
||||
bases.push(
|
||||
path.join(home, '.config', 'google-chrome'),
|
||||
path.join(home, '.config', 'chromium'),
|
||||
path.join(home, '.config', 'microsoft-edge'),
|
||||
);
|
||||
} else if (platform === 'win32') {
|
||||
const appData = process.env.LOCALAPPDATA || path.join(home, 'AppData', 'Local');
|
||||
bases.push(
|
||||
path.join(appData, 'Google', 'Chrome', 'User Data'),
|
||||
path.join(appData, 'Microsoft', 'Edge', 'User Data'),
|
||||
);
|
||||
}
|
||||
|
||||
const profiles = ['Default', 'Profile 1', 'Profile 2', 'Profile 3'];
|
||||
// Token is 43 chars of base64url (from 32 random bytes)
|
||||
const tokenRe = /([A-Za-z0-9_-]{40,50})/;
|
||||
|
||||
for (const base of bases) {
|
||||
for (const profile of profiles) {
|
||||
const dir = path.join(base, profile, 'Local Storage', 'leveldb');
|
||||
if (!fileExists(dir)) continue;
|
||||
|
||||
// Fast path: use strings + grep to find candidate files and extract token
|
||||
if (platform !== 'win32') {
|
||||
const token = extractTokenViaStrings(dir, tokenRe);
|
||||
if (token) return token;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Slow path (Windows): read binary files directly
|
||||
const token = extractTokenViaBinaryRead(dir, tokenRe);
|
||||
if (token) return token;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function extractTokenViaStrings(dir: string, tokenRe: RegExp): string | null {
|
||||
try {
|
||||
// Step 1: Find files containing the extension ID
|
||||
const { execSync } = require('node:child_process') as typeof import('node:child_process');
|
||||
const grepResult = execSync(
|
||||
`grep -rl '${PLAYWRIGHT_EXTENSION_ID}' "${dir}" 2>/dev/null | head -10`,
|
||||
{ encoding: 'utf-8', timeout: 5000 },
|
||||
).trim();
|
||||
if (!grepResult) return null;
|
||||
|
||||
const candidateFiles = grepResult.split('\n').filter(Boolean);
|
||||
|
||||
for (const file of candidateFiles) {
|
||||
// Step 2: Extract strings containing "auth-token" or the extension ID pattern
|
||||
try {
|
||||
const stringsOutput = execSync(
|
||||
`strings "${file}" 2>/dev/null`,
|
||||
{ encoding: 'utf-8', timeout: 5000, maxBuffer: 4 * 1024 * 1024 },
|
||||
);
|
||||
|
||||
// Look for the token pattern near the extension ID + auth-token key
|
||||
// LevelDB stores: "EXTENSION_ID.N\0...\0auth-token\0R\n...TOKEN_VALUE"
|
||||
const lines = stringsOutput.split('\n');
|
||||
let foundExtKey = false;
|
||||
for (const line of lines) {
|
||||
if (line.includes(PLAYWRIGHT_EXTENSION_ID)) foundExtKey = true;
|
||||
if (foundExtKey && line.includes('auth-token')) {
|
||||
// The token should appear in a nearby line
|
||||
foundExtKey = false; // reset
|
||||
continue;
|
||||
}
|
||||
if (foundExtKey || line.includes('auth-token')) {
|
||||
const m = line.match(tokenRe);
|
||||
if (m && validateBase64urlToken(m[1])) return m[1];
|
||||
}
|
||||
}
|
||||
|
||||
// Also try: look for token value after "EXTENSION_ID.N" pattern
|
||||
const extKeyMatch = stringsOutput.match(
|
||||
new RegExp(`${PLAYWRIGHT_EXTENSION_ID}\\.\\d[\\s\\S]{0,100}?${tokenRe.source}`),
|
||||
);
|
||||
if (extKeyMatch?.[1] && validateBase64urlToken(extKeyMatch[1])) return extKeyMatch[1];
|
||||
} catch { continue; }
|
||||
}
|
||||
} catch {}
|
||||
return null;
|
||||
}
|
||||
|
||||
function extractTokenViaBinaryRead(dir: string, tokenRe: RegExp): string | null {
|
||||
const extIdBuf = Buffer.from(PLAYWRIGHT_EXTENSION_ID);
|
||||
const keyBuf = Buffer.from('auth-token');
|
||||
|
||||
let files: string[];
|
||||
try {
|
||||
files = fs.readdirSync(dir)
|
||||
.filter(f => f.endsWith('.ldb') || f.endsWith('.log'))
|
||||
.map(f => path.join(dir, f));
|
||||
} catch { return null; }
|
||||
|
||||
// Sort by mtime descending
|
||||
files.sort((a, b) => {
|
||||
try { return fs.statSync(b).mtimeMs - fs.statSync(a).mtimeMs; } catch { return 0; }
|
||||
});
|
||||
|
||||
for (const file of files) {
|
||||
let data: Buffer;
|
||||
try { data = fs.readFileSync(file); } catch { continue; }
|
||||
|
||||
// Quick check: does file contain both the extension ID and auth-token key?
|
||||
const extPos = data.indexOf(extIdBuf);
|
||||
if (extPos === -1) continue;
|
||||
const keyPos = data.indexOf(keyBuf, Math.max(0, extPos - 500));
|
||||
if (keyPos === -1) continue;
|
||||
|
||||
// Scan for token value after auth-token key
|
||||
let idx = 0;
|
||||
while (true) {
|
||||
const kp = data.indexOf(keyBuf, idx);
|
||||
if (kp === -1) break;
|
||||
|
||||
const contextStart = Math.max(0, kp - 500);
|
||||
if (data.indexOf(extIdBuf, contextStart) !== -1 && data.indexOf(extIdBuf, contextStart) < kp) {
|
||||
const after = data.subarray(kp + keyBuf.length, kp + keyBuf.length + 200).toString('latin1');
|
||||
const m = after.match(tokenRe);
|
||||
if (m && validateBase64urlToken(m[1])) return m[1];
|
||||
}
|
||||
idx = kp + 1;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function validateBase64urlToken(token: string): boolean {
|
||||
try {
|
||||
const b64 = token.replace(/-/g, '+').replace(/_/g, '/');
|
||||
const decoded = Buffer.from(b64, 'base64');
|
||||
return decoded.length >= 28 && decoded.length <= 36;
|
||||
} catch { return false; }
|
||||
}
|
||||
|
||||
|
||||
export async function runBrowserDoctor(opts: DoctorOptions = {}): Promise<DoctorReport> {
|
||||
@@ -234,19 +393,25 @@ export async function runBrowserDoctor(opts: DoctorOptions = {}): Promise<Doctor
|
||||
const configPaths = opts.configPaths?.length ? opts.configPaths : getDefaultMcpConfigPaths();
|
||||
const configs = configPaths.map(readConfigStatus);
|
||||
|
||||
// Try to discover the token directly from the Chrome extension's localStorage
|
||||
const extensionToken = discoverExtensionToken();
|
||||
|
||||
const allTokens = [
|
||||
opts.token ?? null,
|
||||
extensionToken,
|
||||
envToken,
|
||||
...shellFiles.map(s => s.token),
|
||||
...configs.map(c => c.token),
|
||||
].filter((v): v is string => !!v);
|
||||
const uniqueTokens = [...new Set(allTokens)];
|
||||
const recommendedToken = opts.token ?? envToken ?? (uniqueTokens.length === 1 ? uniqueTokens[0] : null) ?? null;
|
||||
const recommendedToken = opts.token ?? extensionToken ?? envToken ?? (uniqueTokens.length === 1 ? uniqueTokens[0] : null) ?? null;
|
||||
|
||||
const report: DoctorReport = {
|
||||
cliVersion: opts.cliVersion,
|
||||
envToken,
|
||||
envFingerprint: getTokenFingerprint(envToken ?? undefined),
|
||||
extensionToken,
|
||||
extensionFingerprint: getTokenFingerprint(extensionToken ?? undefined),
|
||||
shellFiles,
|
||||
configs,
|
||||
recommendedToken,
|
||||
@@ -270,6 +435,7 @@ export async function runBrowserDoctor(opts: DoctorOptions = {}): Promise<Doctor
|
||||
|
||||
export function renderBrowserDoctorReport(report: DoctorReport): string {
|
||||
const tokenFingerprints = [
|
||||
report.extensionFingerprint,
|
||||
report.envFingerprint,
|
||||
...report.shellFiles.map(shell => shell.fingerprint),
|
||||
...report.configs.filter(config => config.exists).map(config => config.fingerprint),
|
||||
@@ -278,6 +444,9 @@ export function renderBrowserDoctorReport(report: DoctorReport): string {
|
||||
const hasMismatch = uniqueFingerprints.length > 1;
|
||||
const lines = [`opencli v${report.cliVersion ?? 'unknown'} doctor`, ''];
|
||||
|
||||
const extStatus: ReportStatus = !report.extensionToken ? 'MISSING' : hasMismatch ? 'MISMATCH' : 'OK';
|
||||
lines.push(statusLine(extStatus, `Extension token (Chrome LevelDB): ${tokenSummary(report.extensionToken, report.extensionFingerprint)}`));
|
||||
|
||||
const envStatus: ReportStatus = !report.envToken ? 'MISSING' : hasMismatch ? 'MISMATCH' : 'OK';
|
||||
lines.push(statusLine(envStatus, `Environment token: ${tokenSummary(report.envToken, report.envFingerprint)}`));
|
||||
|
||||
|
||||
Reference in new issue
Block a user