9b395f5cc3
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
Build Chrome Extension / build (push) Has been cancelled
Trigger Website Rebuild (Docs Updated) / dispatch (push) Has been cancelled
228 lines
10 KiB
JavaScript
228 lines
10 KiB
JavaScript
import { CommandExecutionError } from '@jackwener/opencli/errors';
|
|
import { Strategy } from '@jackwener/opencli/registry';
|
|
import { assertUsableState, buildProvenance, cleanText, extractAsin, extractCategoryNodeId, extractReviewCountFromCardText, firstMeaningfulLine, gotoAndReadState, isRankingPaginationUrl, normalizeProductUrl, parsePriceText, parseRatingValue, parseReviewCount, resolveRankingUrl, toAbsoluteAmazonUrl, uniqueNonEmpty, } from './shared.js';
|
|
function parseRank(rawRank, fallback) {
|
|
const normalized = cleanText(rawRank);
|
|
const match = normalized.match(/(\d{1,4})/);
|
|
if (!match)
|
|
return fallback;
|
|
const parsed = Number.parseInt(match[1], 10);
|
|
return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
|
|
}
|
|
function normalizeVisibleCategoryLinks(links) {
|
|
const normalized = (links ?? [])
|
|
.map((entry) => ({
|
|
title: cleanText(entry?.title),
|
|
url: toAbsoluteAmazonUrl(entry?.url) ?? '',
|
|
node_id: cleanText(entry?.node_id) || extractCategoryNodeId(entry?.url) || null,
|
|
}))
|
|
.filter((entry) => Boolean(entry.title) && Boolean(entry.url));
|
|
const seen = new Set();
|
|
const deduped = [];
|
|
for (const entry of normalized) {
|
|
if (seen.has(entry.url))
|
|
continue;
|
|
seen.add(entry.url);
|
|
deduped.push(entry);
|
|
}
|
|
return deduped;
|
|
}
|
|
export function normalizeRankingCandidate(candidate, context) {
|
|
const productUrl = normalizeProductUrl(candidate.href);
|
|
const asin = extractAsin(candidate.asin ?? '') ?? extractAsin(productUrl ?? '') ?? null;
|
|
const title = cleanText(candidate.title) || firstMeaningfulLine(candidate.card_text);
|
|
const price = parsePriceText(cleanText(candidate.price_text) || candidate.card_text);
|
|
const ratingText = cleanText(candidate.rating_text) || null;
|
|
const reviewCountText = cleanText(candidate.review_count_text)
|
|
|| extractReviewCountFromCardText(candidate.card_text)
|
|
|| null;
|
|
const provenance = buildProvenance(context.sourceUrl);
|
|
const categoryUrl = context.categoryUrl || context.sourceUrl;
|
|
return {
|
|
list_type: context.listType,
|
|
rank: parseRank(candidate.rank_text, context.rankFallback),
|
|
asin,
|
|
title: title || null,
|
|
product_url: productUrl,
|
|
price_text: price.price_text,
|
|
price_value: price.price_value,
|
|
currency: price.currency,
|
|
rating_text: ratingText,
|
|
rating_value: parseRatingValue(ratingText),
|
|
review_count_text: reviewCountText,
|
|
review_count: parseReviewCount(reviewCountText),
|
|
list_title: context.listTitle,
|
|
category_title: context.categoryTitle,
|
|
category_url: categoryUrl,
|
|
category_node_id: extractCategoryNodeId(categoryUrl),
|
|
category_path: context.categoryPath,
|
|
visible_category_links: context.visibleCategoryLinks,
|
|
...provenance,
|
|
};
|
|
}
|
|
async function readRankingPage(page, listType, url) {
|
|
const state = await gotoAndReadState(page, url, 2500, listType);
|
|
assertUsableState(state, listType);
|
|
return await page.evaluate(`
|
|
(() => ({
|
|
href: window.location.href,
|
|
title: document.title || '',
|
|
list_title:
|
|
document.querySelector('#zg_banner_text')?.textContent
|
|
|| document.querySelector('h1')?.textContent
|
|
|| '',
|
|
category_title:
|
|
document.querySelector('#zg_browseRoot .zg_selected')?.textContent
|
|
|| document.querySelector('#wayfinding-breadcrumbs_feature_div ul li:last-child')?.textContent
|
|
|| document.querySelector('#wayfinding-breadcrumbs_container ul li:last-child')?.textContent
|
|
|| '',
|
|
category_path: Array.from(document.querySelectorAll(
|
|
'#zg_browseRoot ul li a, #zg_browseRoot ul li span, ' +
|
|
'#wayfinding-breadcrumbs_feature_div ul li a, #wayfinding-breadcrumbs_feature_div ul li span.a-list-item, ' +
|
|
'#wayfinding-breadcrumbs_container ul li a, #wayfinding-breadcrumbs_container ul li span.a-list-item'
|
|
))
|
|
.map((entry) => (entry.textContent || '').trim())
|
|
.filter(Boolean),
|
|
cards: Array.from(document.querySelectorAll(
|
|
'.p13n-sc-uncoverable-faceout, .zg-grid-general-faceout, [data-asin][class*="p13n"]'
|
|
)).map((card) => ({
|
|
rank_text:
|
|
card.querySelector('.zg-bdg-text')?.textContent
|
|
|| card.querySelector('[class*="rank"]')?.textContent
|
|
|| '',
|
|
asin:
|
|
card.getAttribute('data-asin')
|
|
|| card.getAttribute('id')
|
|
|| '',
|
|
title:
|
|
card.querySelector('[class*="line-clamp"]')?.textContent
|
|
|| card.querySelector('img')?.getAttribute('alt')
|
|
|| card.querySelector('a[href*="/dp/"]')?.textContent
|
|
|| '',
|
|
href:
|
|
card.querySelector('a[href*="/dp/"], a[href*="/gp/product/"]')?.href
|
|
|| '',
|
|
price_text:
|
|
card.querySelector('.a-price .a-offscreen')?.textContent
|
|
|| card.querySelector('.a-color-price')?.textContent
|
|
|| '',
|
|
rating_text:
|
|
card.querySelector('[aria-label*="out of 5 stars"]')?.getAttribute('aria-label')
|
|
|| '',
|
|
review_count_text:
|
|
card.querySelector('a[href*="#customerReviews"]')?.textContent
|
|
|| card.querySelector('.a-size-small')?.textContent
|
|
|| '',
|
|
card_text: card.innerText || '',
|
|
})),
|
|
page_links: Array.from(document.querySelectorAll('.a-pagination a[href], li.a-normal a[href], li.a-selected a[href]'))
|
|
.map((anchor) => anchor.href || '')
|
|
.filter(Boolean),
|
|
visible_category_links: Array.from(document.querySelectorAll(
|
|
'#zg_browseRoot a[href], #zg-left-col a[href], [class*="zg-browse"] a[href]'
|
|
)).map((anchor) => ({
|
|
title: (anchor.textContent || '').trim(),
|
|
url: anchor.href || '',
|
|
node_id:
|
|
anchor.getAttribute('data-node-id')
|
|
|| anchor.dataset?.nodeid
|
|
|| '',
|
|
}))
|
|
.filter((entry) => entry.title && entry.url),
|
|
}))()
|
|
`);
|
|
}
|
|
function createEmptyResultHint(commandName) {
|
|
return [
|
|
`Open the same Amazon ${commandName} page in shared Chrome and verify ranked items are visible.`,
|
|
'If the page shows a robot check, clear it manually and retry.',
|
|
].join(' ');
|
|
}
|
|
export function createRankingCliOptions(definition) {
|
|
return {
|
|
site: 'amazon',
|
|
name: definition.commandName,
|
|
access: definition.access ?? 'read',
|
|
description: definition.description,
|
|
domain: 'amazon.com',
|
|
strategy: Strategy.COOKIE,
|
|
navigateBefore: false,
|
|
args: [
|
|
{
|
|
name: 'input',
|
|
positional: true,
|
|
help: 'Ranking URL or supported Amazon path. Omit to use the list root.',
|
|
},
|
|
{
|
|
name: 'limit',
|
|
type: 'int',
|
|
default: 100,
|
|
help: 'Maximum number of ranked items to return (default 100)',
|
|
},
|
|
],
|
|
columns: ['list_type', 'rank', 'asin', 'title', 'price_text', 'rating_value', 'review_count'],
|
|
func: async (page, kwargs) => {
|
|
const limit = Math.max(1, Number(kwargs.limit) || 100);
|
|
const initialUrl = resolveRankingUrl(definition.listType, typeof kwargs.input === 'string' ? kwargs.input : undefined);
|
|
const queue = [initialUrl];
|
|
const visited = new Set();
|
|
const seenEntityKeys = new Set();
|
|
const results = [];
|
|
let listTitle = null;
|
|
while (queue.length > 0 && results.length < limit) {
|
|
const nextUrl = queue.shift();
|
|
if (visited.has(nextUrl))
|
|
continue;
|
|
visited.add(nextUrl);
|
|
const payload = await readRankingPage(page, definition.listType, nextUrl);
|
|
const sourceUrl = cleanText(payload.href) || nextUrl;
|
|
listTitle = cleanText(payload.list_title) || cleanText(payload.title) || listTitle;
|
|
const categoryPath = uniqueNonEmpty(payload.category_path ?? []);
|
|
const categoryTitle = cleanText(payload.category_title)
|
|
|| (categoryPath.length > 0 ? categoryPath[categoryPath.length - 1] : '');
|
|
const visibleCategoryLinks = normalizeVisibleCategoryLinks(payload.visible_category_links);
|
|
const cards = payload.cards ?? [];
|
|
for (const card of cards) {
|
|
const normalized = normalizeRankingCandidate(card, {
|
|
listType: definition.listType,
|
|
rankFallback: results.length + 1,
|
|
listTitle,
|
|
sourceUrl,
|
|
categoryTitle: categoryTitle || null,
|
|
categoryUrl: sourceUrl,
|
|
categoryPath,
|
|
visibleCategoryLinks,
|
|
});
|
|
const dedupeKey = cleanText(String(normalized.asin ?? ''))
|
|
|| cleanText(String(normalized.product_url ?? ''));
|
|
if (dedupeKey && seenEntityKeys.has(dedupeKey))
|
|
continue;
|
|
if (dedupeKey)
|
|
seenEntityKeys.add(dedupeKey);
|
|
results.push(normalized);
|
|
if (results.length >= limit)
|
|
break;
|
|
}
|
|
const pageLinks = uniqueNonEmpty(payload.page_links ?? []);
|
|
for (const href of pageLinks) {
|
|
const absolute = toAbsoluteAmazonUrl(href);
|
|
if (!absolute || !isRankingPaginationUrl(definition.listType, absolute))
|
|
continue;
|
|
if (!visited.has(absolute) && !queue.includes(absolute)) {
|
|
queue.push(absolute);
|
|
}
|
|
}
|
|
}
|
|
if (results.length === 0) {
|
|
throw new CommandExecutionError(`amazon ${definition.commandName} did not expose any ranked items`, createEmptyResultHint(definition.commandName));
|
|
}
|
|
return results.slice(0, limit);
|
|
},
|
|
};
|
|
}
|
|
export const __test__ = {
|
|
parseRank,
|
|
normalizeVisibleCategoryLinks,
|
|
normalizeRankingCandidate,
|
|
};
|