import { ArgumentError, CommandExecutionError } from '@jackwener/opencli/errors'; export const SITE = 'amazon'; export const DOMAIN = 'amazon.com'; export const HOME_URL = 'https://www.amazon.com/'; export const BESTSELLERS_URL = 'https://www.amazon.com/Best-Sellers/zgbs'; export const NEW_RELEASES_URL = 'https://www.amazon.com/gp/new-releases'; export const MOVERS_SHAKERS_URL = 'https://www.amazon.com/gp/movers-and-shakers'; export const SEARCH_URL_PREFIX = 'https://www.amazon.com/s?k='; export const PRODUCT_URL_PREFIX = 'https://www.amazon.com/dp/'; export const DISCUSSION_URL_PREFIX = 'https://www.amazon.com/product-reviews/'; export const STRATEGY = 'cookie'; export const PRIMARY_PRICE_SELECTORS = [ '#corePrice_feature_div .a-offscreen', '#corePriceDisplay_desktop_feature_div .a-offscreen', '#corePrice_desktop .a-offscreen', '#apex_desktop .a-offscreen', '#newAccordionRow_0 .a-offscreen', '#price_inside_buybox', '#priceblock_ourprice', '#priceblock_dealprice', '#tp_price_block_total_price_ww', ]; const ROBOT_TEXT_PATTERNS = [ 'Sorry, we just need to make sure you\'re not a robot', 'Enter the characters you see below', 'Type the characters you see in this image', 'To discuss automated access to Amazon data please contact', ]; const AMAZON_RANKING_SPECS = { bestsellers: { commandName: 'bestsellers', rootUrl: BESTSELLERS_URL, pathPattern: /(?:^|\/)zgbs(?:\/|$)/i, invalidInputMessage: 'amazon bestsellers expects a best sellers URL or /zgbs path', invalidInputHint: 'Example: opencli amazon bestsellers https://www.amazon.com/Best-Sellers/zgbs', }, new_releases: { commandName: 'new-releases', rootUrl: NEW_RELEASES_URL, pathPattern: /\/gp\/new-releases(?:\/|$)/i, invalidInputMessage: 'amazon new-releases expects a new releases URL or /gp/new-releases path', invalidInputHint: 'Example: opencli amazon new-releases https://www.amazon.com/gp/new-releases', }, movers_shakers: { commandName: 'movers-shakers', rootUrl: MOVERS_SHAKERS_URL, pathPattern: /\/gp\/movers-and-shakers(?:\/|$)/i, invalidInputMessage: 'amazon movers-shakers expects a movers-and-shakers URL or /gp/movers-and-shakers path', invalidInputHint: 'Example: opencli amazon movers-shakers https://www.amazon.com/gp/movers-and-shakers', }, }; export function cleanText(value) { return typeof value === 'string' ? value.replace(/\u00a0/g, ' ').replace(/\s+/g, ' ').trim() : ''; } export function cleanMultilineText(value) { return typeof value === 'string' ? value .replace(/\u00a0/g, ' ') .split('\n') .map((line) => line.replace(/\s+/g, ' ').trim()) .filter(Boolean) .join('\n') : ''; } export function uniqueNonEmpty(values) { return [...new Set(values.map((value) => cleanText(value)).filter(Boolean))]; } export function buildProvenance(sourceUrl) { return { source_url: sourceUrl, fetched_at: new Date().toISOString(), strategy: STRATEGY, }; } export function buildSearchUrl(query) { const normalized = cleanText(query); if (!normalized) { throw new ArgumentError('amazon search query cannot be empty'); } return `${SEARCH_URL_PREFIX}${encodeURIComponent(normalized)}`; } export function extractAsin(input) { const normalized = cleanText(input); if (!normalized) return null; if (/^[A-Z0-9]{10}$/i.test(normalized)) { return normalized.toUpperCase(); } const match = normalized.match(/\/(?:dp|gp\/product|product-reviews)\/([A-Z0-9]{10})/i); return match ? match[1].toUpperCase() : null; } export function buildProductUrl(input) { const asin = extractAsin(input); if (!asin) { throw new ArgumentError('amazon product expects an ASIN or product URL', 'Example: opencli amazon product B0FJS72893'); } return `${PRODUCT_URL_PREFIX}${asin}`; } export function buildDiscussionUrl(input) { const asin = extractAsin(input); if (!asin) { throw new ArgumentError('amazon discussion expects an ASIN or product URL', 'Example: opencli amazon discussion B0FJS72893'); } return `${DISCUSSION_URL_PREFIX}${asin}`; } function getRankingSpec(listType) { return AMAZON_RANKING_SPECS[listType]; } export function isSupportedRankingPath(listType, inputUrl) { try { const url = new URL(inputUrl); return getRankingSpec(listType).pathPattern.test(url.pathname); } catch { return false; } } export function resolveRankingUrl(listType, input) { const spec = getRankingSpec(listType); const normalized = cleanText(input); if (!normalized || normalized === 'root') return spec.rootUrl; let candidateUrl; if (normalized.startsWith('/')) { candidateUrl = new URL(normalized, HOME_URL).toString(); } else if (/^https?:\/\//i.test(normalized)) { candidateUrl = canonicalizeAmazonUrl(normalized); } else if (normalized.includes('amazon.') && normalized.includes('/')) { candidateUrl = canonicalizeAmazonUrl(`https://${normalized.replace(/^\/+/, '')}`); } else { throw new ArgumentError(spec.invalidInputMessage, spec.invalidInputHint); } if (!isSupportedRankingPath(listType, candidateUrl)) { throw new ArgumentError(spec.invalidInputMessage, spec.invalidInputHint); } return normalizeRankingInputUrl(candidateUrl); } function normalizeRankingInputUrl(inputUrl) { try { const url = new URL(inputUrl); const normalizedPathSegments = url.pathname .split('/') .filter(Boolean) .filter((segment) => !/^ref=/i.test(segment)); url.pathname = `/${normalizedPathSegments.join('/')}`; url.hash = ''; // Ranking pages are frequently shared with tracking refs that can land on unstable variants. // Dropping ref keeps the canonical ranking path while preserving useful params (for example pg=2). url.searchParams.delete('ref'); return url.toString(); } catch { return inputUrl; } } export function isRankingPaginationUrl(listType, inputUrl) { const absolute = toAbsoluteAmazonUrl(inputUrl); if (!absolute || !isSupportedRankingPath(listType, absolute)) return false; try { const url = new URL(absolute); const ref = cleanText(url.searchParams.get('ref')).toLowerCase(); // pg= query param is the most reliable pagination indicator across all ranking lists return url.searchParams.has('pg') || /(?:^|_)pg(?:_|$)/.test(ref) // Amazon ranking pagination refs: zg_bs_pg_ (bestsellers), zg_bsnr_pg_ (new releases), zg_bsms_pg_ (movers & shakers) || /zg_bs(?:nr|ms)?_pg_/.test(ref); } catch { return false; } } export function extractCategoryNodeId(inputUrl) { const absolute = toAbsoluteAmazonUrl(inputUrl); if (!absolute) return null; try { const url = new URL(absolute); for (const key of ['node', 'nodeid', 'nodeId', 'browseNode']) { const value = cleanText(url.searchParams.get(key)); if (/^\d{4,}$/.test(value)) return value; } const rhValue = cleanText(url.searchParams.get('rh')); const rhMatch = decodeURIComponent(rhValue).match(/(?:^|,)\s*n:(\d{4,})(?:,|$)/i); if (rhMatch) return rhMatch[1]; const pathMatches = [...url.pathname.matchAll(/\/(\d{4,})(?=\/|$)/g)]; if (pathMatches.length > 0) { return pathMatches[pathMatches.length - 1][1]; } } catch { return null; } return null; } export function resolveBestsellersUrl(input) { return resolveRankingUrl('bestsellers', input); } export function canonicalizeAmazonUrl(input) { try { const url = new URL(input); if (!url.hostname.endsWith(DOMAIN)) { throw new Error('not-amazon'); } return url.toString(); } catch { throw new ArgumentError('Invalid Amazon URL'); } } export function toAbsoluteAmazonUrl(value) { const normalized = cleanText(value); if (!normalized) return null; try { return new URL(normalized, HOME_URL).toString(); } catch { return null; } } export function normalizeProductUrl(value) { const normalized = cleanText(value); const asin = extractAsin(normalized); if (asin) return buildProductUrl(asin); return toAbsoluteAmazonUrl(normalized); } export function parsePriceText(text) { const normalized = cleanText(text); const match = normalized.match(/([$€£])\s*(\d+(?:,\d{3})*(?:\.\d+)?)/); if (!match) { return { price_text: normalized || null, price_value: null, currency: null, }; } const currencyMap = { '$': 'USD', '€': 'EUR', '£': 'GBP', }; return { price_text: `${match[1]}${match[2]}`, price_value: Number.parseFloat(match[2].replace(/,/g, '')), currency: currencyMap[match[1]] ?? null, }; } export function parseRatingValue(text) { const normalized = cleanText(text); const match = normalized.match(/(\d+(?:\.\d+)?)\s*out of 5/i); return match ? Number.parseFloat(match[1]) : null; } export function parseReviewCount(text) { const normalized = cleanText(text); const compactMatch = normalized.match(/(\d+(?:\.\d+)?)\s*([kKmM])/); if (compactMatch) { const value = Number.parseFloat(compactMatch[1]); const multiplier = /m/i.test(compactMatch[2]) ? 1_000_000 : 1_000; return Number.isFinite(value) ? Math.round(value * multiplier) : null; } const match = normalized.match(/([\d,]+)/); return match ? Number.parseInt(match[1].replace(/,/g, ''), 10) : null; } export function extractReviewCountFromCardText(text) { const normalized = cleanMultilineText(text); const match = normalized.match(/out of 5 stars(?:, rating details)?\s*([\d,]+)/i); if (match) return match[1]; const numericLine = normalized .split('\n') .map((line) => cleanText(line)) .find((line) => /^[\d,]+$/.test(line)); return numericLine ?? null; } export function isAmazonEntity(text) { const normalized = cleanText(text).toLowerCase(); return normalized.includes('amazon'); } export function firstMeaningfulLine(text) { return cleanMultilineText(text) .split('\n') .map((line) => cleanText(line)) .find(Boolean) ?? ''; } export function trimRatingPrefix(text) { const normalized = cleanText(text); if (!normalized) return null; return normalized.replace(/^\d+(?:\.\d+)?\s*out of 5 stars\s*/i, '').trim() || normalized; } export function isRobotState(state) { const title = cleanText(state.title); const bodyText = cleanMultilineText(state.body_text); return ROBOT_TEXT_PATTERNS.some((pattern) => title.includes(pattern) || bodyText.includes(pattern)); } export function buildChallengeHint(action) { return [ `Open a clean Amazon ${action} page in the shared Chrome profile and clear any robot check first.`, 'If you are using CDP, set OPENCLI_CDP_TARGET=amazon.com and avoid parallel Amazon commands against the same browser target.', ].join(' '); } export async function readPageState(page) { const result = await page.evaluate(` (() => ({ href: window.location.href, title: document.title || '', body_text: document.body ? document.body.innerText || '' : '', }))() `); return { href: cleanText(result.href), title: cleanText(result.title), body_text: cleanMultilineText(result.body_text), }; } export async function gotoAndReadState(page, url, settleMs = 2500, action = 'page') { try { await page.goto(url, { settleMs }); await page.wait(1.5); return await readPageState(page); } catch (error) { const message = error instanceof Error ? error.message : String(error); if (message.includes('Inspected target navigated or closed') || message.includes('Cannot find context with specified id') || message.includes('Target closed')) { throw new CommandExecutionError(`amazon ${action} navigation lost the current browser target`, `${buildChallengeHint(action)} If CDP is attached to a stale tab, open a fresh Amazon tab and retry.`); } throw error; } } export function assertUsableState(state, action) { if (!isRobotState(state)) return; throw new CommandExecutionError(`amazon ${action} hit a robot check`, buildChallengeHint(action)); } export const __test__ = { buildSearchUrl, extractAsin, buildProductUrl, buildDiscussionUrl, resolveBestsellersUrl, resolveRankingUrl, isSupportedRankingPath, isRankingPaginationUrl, extractCategoryNodeId, parsePriceText, parseRatingValue, parseReviewCount, extractReviewCountFromCardText, isAmazonEntity, trimRatingPrefix, isRobotState, PRIMARY_PRICE_SELECTORS, };