diff --git a/README.md b/README.md index 2ee8d40..22f8a04 100644 --- a/README.md +++ b/README.md @@ -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 | diff --git a/README.zh-CN.md b/README.zh-CN.md index b87a573..53714b6 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -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` | 🔐 浏览器 | diff --git a/src/clis/coupang/add-to-cart.ts b/src/clis/coupang/add-to-cart.ts new file mode 100644 index 0000000..4a53638 --- /dev/null +++ b/src/clis/coupang/add-to-cart.ts @@ -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', + }]; + }, +}); diff --git a/src/clis/coupang/search.ts b/src/clis/coupang/search.ts new file mode 100644 index 0000000..e06b27c --- /dev/null +++ b/src/clis/coupang/search.ts @@ -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, index: number) => normalizeSearchItem(item, index)), + limit + ); + const normalizedDom = sanitizeSearchItems( + domItems.map((item: Record, 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; + }, +}); diff --git a/src/coupang.test.ts b/src/coupang.test.ts new file mode 100644 index 0000000..87d066d --- /dev/null +++ b/src/coupang.test.ts @@ -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]); + }); +}); diff --git a/src/coupang.ts b/src/coupang.ts new file mode 100644 index 0000000..ac0c1fe --- /dev/null +++ b/src/coupang.ts @@ -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 = { + ROCKET: '로켓배송', + ROCKET_MERCHANT: '판매자로켓', + ROCKET_WOW: '로켓와우', + WOW: '로켓와우', + ROCKET_FRESH: '로켓프레시', + FRESH: '로켓프레시', + SELLER_ROCKET: '판매자로켓', + ROCKET_JIKGU: '로켓직구', + JIKGU: '로켓직구', + COUPANG_GLOBAL: '로켓직구', +}; + +const BADGE_ID_TO_PROMISE: Record = { + 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, 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))) { + ok = false; + break; + } + current = (current as Record)[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, 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(); + 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(); + 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); +} diff --git a/src/doctor.test.ts b/src/doctor.test.ts index b83bc8e..4d74579 100644 --- a/src/doctor.test.ts +++ b/src/doctor.test.ts @@ -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', diff --git a/src/doctor.ts b/src/doctor.ts index 6640f83..3caf8e0 100644 --- a/src/doctor.ts +++ b/src/doctor.ts @@ -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 { @@ -234,19 +393,25 @@ export async function runBrowserDoctor(opts: DoctorOptions = {}): Promise 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 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)}`));