9b395f5cc3
Build Chrome Extension / build (push) Waiting to run
Trigger Website Rebuild (Docs Updated) / dispatch (push) Waiting to run
E2E Headed Chrome / e2e-headed (macos-15) (push) Has been cancelled
E2E Headed Chrome / e2e-headed (ubuntu-latest) (push) Has been cancelled
E2E Headed Chrome / e2e-headed (windows-latest) (push) Has been cancelled
CI / build (macos-latest) (push) Has been cancelled
CI / build (ubuntu-latest) (push) Has been cancelled
CI / build (windows-latest) (push) Has been cancelled
CI / unit-test (push) Has been cancelled
CI / bun-test (push) Has been cancelled
CI / adapter-test (push) Has been cancelled
CI / smoke-test (macos-latest) (push) Has been cancelled
CI / smoke-test (ubuntu-latest) (push) Has been cancelled
Security Audit / audit (push) Has been cancelled
366 lines
13 KiB
JavaScript
366 lines
13 KiB
JavaScript
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,
|
|
};
|