Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions plugins/amazon/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,3 +21,7 @@ webcmd plugin install github:agentrhq/webcmd/amazon
| `webcmd amazon product` | Amazon product page facts for candidate validation |
| `webcmd amazon search` | Amazon search results for product discovery and coarse filtering |
| `webcmd amazon whoami` | Show the current logged-in amazon account |

## Notes

- A product or review URL from a sibling marketplace (`amazon.co.uk`, `amazon.de`, `amazon.com.au`, …) is read on that marketplace, and the emitted URLs stay on it. A bare ASIN still defaults to `amazon.com`.
9 changes: 5 additions & 4 deletions plugins/amazon/discussion.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { AuthRequiredError, CommandExecutionError } from '@agentrhq/webcmd/errors';
import { cli, Strategy } from '@agentrhq/webcmd/registry';
import { buildProductUrl, buildDiscussionUrl, buildProvenance, cleanText, extractAsin, normalizeProductUrl, parseRatingValue, parseReviewCount, trimRatingPrefix, uniqueNonEmpty, assertUsableState, gotoAndReadState, } from './shared.js';
import { DOMAIN, amazonHostFromInput, buildProductUrl, buildDiscussionUrl, buildProvenance, cleanText, extractAsin, normalizeProductUrl, parseRatingValue, parseReviewCount, trimRatingPrefix, uniqueNonEmpty, assertUsableState, gotoAndReadState, } from './shared.js';
function normalizeDiscussionPayload(payload) {
const sourceUrl = cleanText(payload.href) || buildDiscussionUrl(payload.href ?? '');
const asin = extractAsin(payload.href ?? '') ?? null;
Expand All @@ -9,7 +9,7 @@ function normalizeDiscussionPayload(payload) {
const provenance = buildProvenance(sourceUrl);
return {
asin,
product_url: asin ? normalizeProductUrl(asin) : null,
product_url: asin ? normalizeProductUrl(sourceUrl) : null,
discussion_url: sourceUrl,
...provenance,
average_rating_text: averageRatingText,
Expand Down Expand Up @@ -71,7 +71,7 @@ async function readDiscussionPayload(page, input, limit) {
const productState = await gotoAndReadState(page, productUrl, 2500, 'discussion');
assertUsableState(productState, 'discussion');
if (isSignInState(reviewState) && isSignInState(productState)) {
throw new AuthRequiredError('amazon.com', 'Amazon review discussion requires an active signed-in Amazon session in the shared Chrome profile.');
throw new AuthRequiredError(amazonHostFromInput(input) ?? DOMAIN, 'Amazon review discussion requires an active signed-in Amazon session in the shared Chrome profile.');
}
const productPayload = await readCurrentDiscussionPayload(page, limit);
if (hasDiscussionSummary(productPayload)) {
Expand Down Expand Up @@ -111,7 +111,8 @@ cli({
const payload = await readDiscussionPayload(page, input, limit);
const normalized = normalizeDiscussionPayload(payload);
if (!normalized.average_rating_text && !normalized.total_review_count_text) {
throw new CommandExecutionError('amazon discussion page did not expose review summary', 'The review page may have changed or hit a robot check. Open the review page in Chrome and retry.');
const landedUrl = cleanText(payload.href) || buildDiscussionUrl(input);
throw new CommandExecutionError(`amazon discussion page did not expose review summary (landed on ${landedUrl})`, 'The review page may have changed or hit a robot check. Open the review page in Chrome and retry.');
}
return [normalized];
},
Expand Down
3 changes: 2 additions & 1 deletion plugins/amazon/product.js
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,8 @@ cli({
const input = String(kwargs.input ?? '');
const payload = await readProductPayload(page, input);
if (!cleanText(payload.product_title)) {
throw new CommandExecutionError('amazon product page did not expose product content', 'The product page may have changed or hit a robot check. Open the product page in Chrome and retry.');
const landedUrl = cleanText(payload.href) || buildProductUrl(input);
throw new CommandExecutionError(`amazon product page did not expose product content (landed on ${landedUrl})`, 'The product page may have changed or hit a robot check. Open the product page in Chrome and retry.');
}
return [normalizeProductPayload(payload)];
},
Expand Down
61 changes: 57 additions & 4 deletions plugins/amazon/shared.js
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,42 @@ export const PRIMARY_PRICE_SELECTORS = [
'#priceblock_dealprice',
'#tp_price_block_total_price_ww',
];
// Keep this explicit because these hosts are navigation targets in the user's
// signed-in browser. A shape-only `amazon.<tld>` pattern also accepts unrelated
// registrable domains such as amazon.shop or amazon.zip.
const MARKETPLACE_DOMAINS = new Set([
'amazon.com',
'amazon.ca',
'amazon.com.mx',
'amazon.com.br',
'amazon.co.uk',
'amazon.de',
'amazon.fr',
'amazon.it',
'amazon.es',
'amazon.nl',
'amazon.pl',
'amazon.se',
'amazon.com.be',
'amazon.ie',
'amazon.com.tr',
'amazon.ae',
'amazon.sa',
'amazon.eg',
'amazon.co.za',
'amazon.in',
'amazon.co.jp',
'amazon.com.au',
'amazon.sg',
]);
function isAmazonMarketplaceHost(hostname) {
const normalized = cleanText(hostname).toLowerCase().replace(/\.$/, '');
for (const domain of MARKETPLACE_DOMAINS) {
if (normalized === domain || normalized.endsWith(`.${domain}`))
return true;
}
return false;
}
const ROBOT_TEXT_PATTERNS = [
'Sorry, we just need to make sure you\'re not a robot',
'Enter the characters you see below',
Expand Down Expand Up @@ -91,19 +127,33 @@ export function extractAsin(input) {
const match = normalized.match(/\/(?:dp|gp\/product|product-reviews)\/([A-Z0-9]{10})/i);
return match ? match[1].toUpperCase() : null;
}
export function amazonHostFromInput(input) {
const normalized = cleanText(input);
if (!normalized)
return null;
try {
const url = new URL(normalized);
return isAmazonMarketplaceHost(url.hostname) ? url.hostname : null;
}
catch {
return null;
}
}
export function buildProductUrl(input) {
const asin = extractAsin(input);
if (!asin) {
throw new ArgumentError('amazon product expects an ASIN or product URL', 'Example: webcmd amazon product B0FJS72893');
}
return `${PRODUCT_URL_PREFIX}${asin}`;
const host = amazonHostFromInput(input);
return host ? `https://${host}/dp/${asin}` : `${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: webcmd amazon discussion B0FJS72893');
}
return `${DISCUSSION_URL_PREFIX}${asin}`;
const host = amazonHostFromInput(input);
return host ? `https://${host}/product-reviews/${asin}` : `${DISCUSSION_URL_PREFIX}${asin}`;
}
function getRankingSpec(listType) {
return AMAZON_RANKING_SPECS[listType];
Expand Down Expand Up @@ -206,7 +256,7 @@ export function resolveBestsellersUrl(input) {
export function canonicalizeAmazonUrl(input) {
try {
const url = new URL(input);
if (!url.hostname.endsWith(DOMAIN)) {
if (!isAmazonMarketplaceHost(url.hostname)) {
throw new Error('not-amazon');
}
return url.toString();
Expand All @@ -230,7 +280,7 @@ export function normalizeProductUrl(value) {
const normalized = cleanText(value);
const asin = extractAsin(normalized);
if (asin)
return buildProductUrl(asin);
return buildProductUrl(normalized);
return toAbsoluteAmazonUrl(normalized);
}
export function parsePriceText(text) {
Expand Down Expand Up @@ -347,8 +397,11 @@ export function assertUsableState(state, action) {
export const __test__ = {
buildSearchUrl,
extractAsin,
amazonHostFromInput,
buildProductUrl,
buildDiscussionUrl,
normalizeProductUrl,
canonicalizeAmazonUrl,
resolveBestsellersUrl,
resolveRankingUrl,
isSupportedRankingPath,
Expand Down
62 changes: 62 additions & 0 deletions plugins/amazon/test/discussion.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,68 @@ describe('amazon discussion normalization', () => {
]);
});

it('keeps the review marketplace in every emitted url', () => {
const result = __test__.normalizeDiscussionPayload({
href: 'https://www.amazon.co.uk/product-reviews/B0FGCPFY9L',
average_rating_text: '4.4 out of 5',
total_review_count_text: '40 global ratings',
qa_links: [],
review_samples: [],
});

expect(result.asin).toBe('B0FGCPFY9L');
expect(result.discussion_url).toBe('https://www.amazon.co.uk/product-reviews/B0FGCPFY9L');
expect(result.product_url).toBe('https://www.amazon.co.uk/dp/B0FGCPFY9L');
});

it('requests the review page on the marketplace the input names', async () => {
const command = getRegistry().get('amazon/discussion');
const page = createPageMock([
{
href: 'https://www.amazon.co.uk/product-reviews/B0FGCPFY9L',
title: 'Amazon.co.uk: Example product',
body_text: 'Customer reviews',
},
{
href: 'https://www.amazon.co.uk/product-reviews/B0FGCPFY9L',
average_rating_text: '4.4 out of 5',
total_review_count_text: '40 global ratings',
review_samples: [],
},
]);

await command.func(page, { input: 'https://www.amazon.co.uk/product-reviews/B0FGCPFY9L', limit: 1 });

expect(page.goto.mock.calls[0][0]).toBe('https://www.amazon.co.uk/product-reviews/B0FGCPFY9L');
});

it('names the loaded url when neither page exposes a review summary', async () => {
const command = getRegistry().get('amazon/discussion');
const emptyPayload = { href: 'https://www.amazon.co.uk/dp/B0FGCPFY9L', average_rating_text: '', total_review_count_text: '', review_samples: [] };
const page = createPageMock([
{ href: 'https://www.amazon.co.uk/product-reviews/B0FGCPFY9L', title: 'Amazon.co.uk', body_text: 'Customer reviews' },
emptyPayload,
{ href: 'https://www.amazon.co.uk/dp/B0FGCPFY9L', title: 'Amazon.co.uk', body_text: 'Product' },
emptyPayload,
]);

await expect(command.func(page, { input: 'https://www.amazon.co.uk/dp/B0FGCPFY9L', limit: 1 }))
.rejects.toThrow('landed on https://www.amazon.co.uk/dp/B0FGCPFY9L');
});

it('points a gated non-US review page at that marketplace, not the US store', async () => {
const command = getRegistry().get('amazon/discussion');
const signIn = { href: 'https://www.amazon.co.uk/ap/signin', title: 'Amazon Sign-In', body_text: 'Sign in Create account' };
const page = createPageMock([
signIn,
{ href: signIn.href, average_rating_text: '', total_review_count_text: '', review_samples: [] },
signIn,
]);

await expect(command.func(page, { input: 'https://www.amazon.co.uk/dp/B0FGCPFY9L', limit: 1 }))
.rejects.toMatchObject({ domain: 'www.amazon.co.uk' });
});

it('falls back to the product page when the review page redirects to sign-in', async () => {
const command = getRegistry().get('amazon/discussion');
const page = createPageMock([
Expand Down
29 changes: 29 additions & 0 deletions plugins/amazon/test/shared.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,35 @@ describe('amazon shared helpers', () => {
expect(__test__.buildProductUrl('https://www.amazon.com/dp/B0FJS72893/ref=something')).toBe('https://www.amazon.com/dp/B0FJS72893');
expect(__test__.buildDiscussionUrl('https://www.amazon.com/dp/B0FJS72893')).toBe('https://www.amazon.com/product-reviews/B0FJS72893');
});
it('keeps the input marketplace instead of rewriting it to the US store', () => {
expect(__test__.buildProductUrl('https://www.amazon.co.uk/dp/B0FGCPFY9L')).toBe('https://www.amazon.co.uk/dp/B0FGCPFY9L');
expect(__test__.buildProductUrl('https://www.amazon.de/dp/B0FJS72893/ref=something')).toBe('https://www.amazon.de/dp/B0FJS72893');
expect(__test__.buildProductUrl('https://www.amazon.com.au/dp/B0FJS72893')).toBe('https://www.amazon.com.au/dp/B0FJS72893');
expect(__test__.buildDiscussionUrl('https://www.amazon.co.uk/product-reviews/B0FGCPFY9L?pageNumber=1')).toBe('https://www.amazon.co.uk/product-reviews/B0FGCPFY9L');
expect(__test__.normalizeProductUrl('https://www.amazon.co.uk/dp/B0FGCPFY9L')).toBe('https://www.amazon.co.uk/dp/B0FGCPFY9L');
});
it('defaults to the US store for bare ASINs and non-marketplace hosts', () => {
expect(__test__.amazonHostFromInput('B0FJS72893')).toBeNull();
expect(__test__.buildProductUrl('B0FJS72893')).toBe('https://www.amazon.com/dp/B0FJS72893');
expect(__test__.buildDiscussionUrl('B0FJS72893')).toBe('https://www.amazon.com/product-reviews/B0FJS72893');
expect(__test__.normalizeProductUrl('B0FJS72893')).toBe('https://www.amazon.com/dp/B0FJS72893');
});
it('accepts sibling marketplaces but rejects look-alike hosts', () => {
expect(__test__.amazonHostFromInput('https://www.amazon.co.uk/dp/B0FJS72893')).toBe('www.amazon.co.uk');
expect(__test__.amazonHostFromInput('https://amazon.de/dp/B0FJS72893')).toBe('amazon.de');
expect(__test__.amazonHostFromInput('https://amazon.com.au/dp/B0FJS72893')).toBe('amazon.com.au');
expect(__test__.amazonHostFromInput('https://smile.amazon.com.be/dp/B0FJS72893')).toBe('smile.amazon.com.be');
expect(__test__.amazonHostFromInput('https://evilamazon.com/dp/B0FJS72893')).toBeNull();
expect(__test__.amazonHostFromInput('https://amazon.com.evil.com/dp/B0FJS72893')).toBeNull();
expect(__test__.amazonHostFromInput('https://amazon.evil.com/dp/B0FJS72893')).toBeNull();
expect(__test__.amazonHostFromInput('https://x.amazon.evil.com/dp/B0FJS72893')).toBeNull();
expect(__test__.amazonHostFromInput('https://amazon.attacker.io/dp/B0FJS72893')).toBeNull();
expect(__test__.amazonHostFromInput('https://amazon.shop/dp/B0FJS72893')).toBeNull();
expect(__test__.amazonHostFromInput('https://amazon.zip/dp/B0FJS72893')).toBeNull();
expect(() => __test__.canonicalizeAmazonUrl('https://amazon.evil.com/gp/bestsellers')).toThrow('Invalid Amazon URL');
expect(__test__.canonicalizeAmazonUrl('https://www.amazon.co.uk/gp/bestsellers/books')).toBe('https://www.amazon.co.uk/gp/bestsellers/books');
expect(() => __test__.canonicalizeAmazonUrl('https://evilamazon.com/gp/bestsellers')).toThrow('Invalid Amazon URL');
});
it('parses price, rating, and review-count text', () => {
expect(__test__.parsePriceText('1 offer from $34.11')).toEqual({
price_text: '$34.11',
Expand Down
34 changes: 29 additions & 5 deletions plugins/facebook/feed.js
Original file line number Diff line number Diff line change
Expand Up @@ -306,12 +306,35 @@ async function loadFeedPosts(page, limit) {
+ Array.from(document.querySelectorAll('[aria-label]')).filter((el) => /^(Actions for this post)$/i.test((el.getAttribute('aria-label') || '').trim())).length
: 0;
})()`;
let prev = -1;
const extractStep = buildFeedExtractScript(limit);
let prevMarkerCount = -1;
let prevRowCount = -1;
let stalledPasses = 0;
for (let i = 0; i < 8; i += 1) {
let count = 0;
try { count = Number(unwrapBrowserResult(await page.evaluate(scrollStep))) || 0; } catch { break; }
if (count >= limit || count === prev) break;
prev = count;
let markerCount = 0;
try { markerCount = Number(unwrapBrowserResult(await page.evaluate(scrollStep))) || 0; } catch { break; }

// Raw article/menu counts include comments, suggestions, and other chrome.
// Do not stop merely because those markers reached --limit; stop
// when the actual feed extractor has enough valid rows.
let rowCount = 0;
try {
const payload = unwrapBrowserResult(await page.evaluate(extractStep));
rowCount = Array.isArray(payload && payload.rows) ? payload.rows.length : 0;
} catch {
// The final extraction below owns error classification. A transient
// observation failure here should only make the bounded scroll continue.
}
if (rowCount >= limit) break;

if (markerCount === prevMarkerCount && rowCount === prevRowCount) {
stalledPasses += 1;
if (stalledPasses >= 2) break;
} else {
stalledPasses = 0;
}
prevMarkerCount = markerCount;
prevRowCount = rowCount;
}
}

Expand Down Expand Up @@ -387,5 +410,6 @@ export const __test__ = {
buildFeedExtractScript,
command,
getFacebookFeed,
loadFeedPosts,
requireLimit,
};
47 changes: 38 additions & 9 deletions plugins/facebook/profile.js
Original file line number Diff line number Diff line change
Expand Up @@ -17,19 +17,48 @@ cli({
pipeline: [
{ navigate: { url: 'https://www.facebook.com/${{ args.username }}', settleMs: 3000 } },
{ evaluate: `(() => {
const h1 = document.querySelector('h1');
let name = h1 ? h1.textContent.trim() : '';
const username = \${{ args.username | json }};
const main = document.querySelector('[role="main"]') || document;
const clean = (value) => String(value || '').replace(/\\s+/g, ' ').trim();
const h1 = main.querySelector('h1');
let name = clean(h1 && h1.textContent);

// Find friends/followers links
const links = Array.from(document.querySelectorAll('a'));
const friendsLink = links.find(a => a.href && a.href.includes('/friends'));
const followersLink = links.find(a => a.href && a.href.includes('/followers'));
// Facebook's current profile header no longer uses an h1. The profile-avatar
// link carries the display name on both the link and its role=img child.
// Scope this fallback to the requested profile path so page chrome cannot win.
if (!name) {
const requestedPrefix = '/' + String(username).toLowerCase() + '/';
const profileMediaLink = Array.from(main.querySelectorAll('a[aria-label][href]')).find((link) => {
const href = link.getAttribute('href') || '';
let path = '';
try { path = new URL(href, window.location.href).pathname.toLowerCase(); } catch {}
const image = link.querySelector('[role="img"][aria-label]');
return path.startsWith(requestedPrefix)
&& image
&& clean(link.getAttribute('aria-label')) === clean(image.getAttribute('aria-label'));
});
name = clean(profileMediaLink && profileMediaLink.getAttribute('aria-label'));
}

// Scope relationship links to the profile main region. Global navigation also
// contains /friends and previously produced empty or generic chrome text.
const links = Array.from(main.querySelectorAll('a[href]'));
const hasProfilePath = (link, suffix) => {
try {
const path = new URL(link.getAttribute('href') || '', window.location.href).pathname
.replace(/\\/+$/, '')
.toLowerCase();
return path === '/' + String(username).toLowerCase() + suffix;
} catch { return false; }
};
const friendsLink = links.find((a) => hasProfilePath(a, '/friends'));
const followersLink = links.find((a) => hasProfilePath(a, '/followers'));

return [{
name: name,
username: \${{ args.username | json }},
friends: friendsLink ? friendsLink.textContent.trim() : '-',
followers: followersLink ? followersLink.textContent.trim() : '-',
username,
friends: friendsLink ? clean(friendsLink.textContent) : '-',
followers: followersLink ? clean(followersLink.textContent) : '-',
url: window.location.href,
}];
})()
Expand Down
Loading