Files
wehub-resource-sync 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
chore: import upstream snapshot with attribution
2026-07-13 12:39:48 +08:00

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,
};