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:
jackwenerandCodeBBakGoSu committed 2026-03-16 13:30:42 +08:00
1 parent 9e024d4e46
commit c77c8a8e3a
8 files changed
+1174 -4

No files matched your search

+2 -1
View File
@@ -9,7 +9,7 @@
[![Node.js Version](https://img.shields.io/node/v/@jackwener/opencli?style=flat-square)](https://nodejs.org)
[![License](https://img.shields.io/npm/l/@jackwener/opencli?style=flat-square)](./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
View File
@@ -9,7 +9,7 @@
[![Node.js Version](https://img.shields.io/node/v/@jackwener/opencli?style=flat-square)](https://nodejs.org)
[![License](https://img.shields.io/npm/l/@jackwener/opencli?style=flat-square)](./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` | 🔐 浏览器 |
+149
View File
@@ -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',
}];
},
});
+466
View File
@@ -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;
},
});
+78
View File
@@ -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
View File
@@ -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);
}
+4
View File
@@ -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
View File
@@ -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)}`));