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
2 changes: 2 additions & 0 deletions plugins/amazon-in/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ webcmd plugin install github:agentrhq/webcmd/amazon-in
| --- | --- |
| `webcmd amazon-in checkout` | Prepare a guarded Amazon.in checkout with browser-only payment handoff |
| `webcmd amazon-in checkout-status` | Read the current Amazon.in checkout or payment state without clicking |
| `webcmd amazon-in cart-add` | Add one confirmed product variant to the authenticated cart |
| `webcmd amazon-in cart` | Read the authenticated cart |
| `webcmd amazon-in login` | Open amazon-in login |
| `webcmd amazon-in product` | Fetch the current Amazon.in price and selected product variant |
| `webcmd amazon-in search` | Search Amazon.in products with inclusive INR price bounds and images |
Expand Down
11 changes: 10 additions & 1 deletion plugins/amazon-in/auth.js
Original file line number Diff line number Diff line change
Expand Up @@ -37,8 +37,17 @@ async function verifyAmazonInIdentity(page) {
registerSiteAuthCommands({
site: SITE,
domain: DOMAIN,
loginUrl: 'https://www.amazon.in/ap/signin',
loginUrl: HOME_URL,
columns: ['user_name'],
quickCheck: hasAmazonInSessionCookies,
verify: verifyAmazonInIdentity,
openLogin: async (page) => {
await page.goto(HOME_URL, { waitUntil: 'load' });
await page.wait(1);
const loginUrl = await page.evaluate(`
(() => document.querySelector('a[href*="/ap/signin"]')?.href || '')()
`);
if (!loginUrl) throw new CommandExecutionError('Amazon.in login link could not be found');
await page.goto(loginUrl, { waitUntil: 'load' });
},
});
87 changes: 87 additions & 0 deletions plugins/amazon-in/cart-add.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
import { AuthRequiredError, ArgumentError, CommandExecutionError } from '@agentrhq/webcmd/errors';
import { cli, Strategy } from '@agentrhq/webcmd/registry';
import { hasAmazonInAuthCookie, buildProductUrl } from './parsers.js';
import { assertUsablePage, gotoAmazon, SITE, DOMAIN } from './shared.js';

async function assertAuthenticated(page) {
const cookies = await page.getCookies({ url: 'https://www.amazon.in/' });
if (!hasAmazonInAuthCookie(cookies.map((cookie) => cookie.name))) {
throw new AuthRequiredError(DOMAIN, 'Amazon.in login is required before changing the cart');
}
}

async function selectVariant(page, dimension, requested) {
if (!requested) return '';
const result = await page.evaluateWithArgs(`
(() => {
const current = (document.querySelector(
'#inline-twister-expanded-dimension-text-' + dimension + '_name, #variation_' + dimension + '_name .selection'
)?.textContent || '').trim();
if (current.toLowerCase() === requested.toLowerCase()) return { current, changed: false };
const options = [...document.querySelectorAll(
'#inline-twister-expander-content-' + dimension + '_name span[id^="' + dimension + '_name_"]:not([id$="-announce"])'
)].filter((node) => !node.classList.contains('aok-hidden'));
const matches = options.filter((node) => {
const label = dimension === 'color' ? (node.querySelector('img')?.alt || '') : (node.textContent || '').trim();
return label.toLowerCase() === requested.toLowerCase();
});
if (matches.length !== 1) return { current, changed: false, matches: matches.length };
(matches[0].querySelector('input') || matches[0]).click();
return { current, changed: true, matches: 1 };
})()
`, { dimension, requested });
if (!result?.changed && result?.current?.toLowerCase() !== requested.toLowerCase()) {
throw new ArgumentError(`${dimension === 'color' ? 'colour' : dimension} "${requested}" is not uniquely available`);
}
if (result.changed) await page.sleep(2);
return requested;
}

cli({
site: SITE,
name: 'cart-add',
access: 'write',
description: 'Add one confirmed Amazon.in product variant to the cart',
domain: DOMAIN,
strategy: Strategy.UI,
browser: true,
navigateBefore: false,
siteSession: 'persistent',
freshPage: true,
args: [
{ name: 'input', required: true, positional: true, help: 'Amazon.in product URL or ASIN' },
{ name: 'size', help: 'Exact visible size label' },
{ name: 'colour', help: 'Exact visible colour label' },
],
columns: ['status', 'asin', 'title', 'size', 'colour', 'action'],
func: async (page, args) => {
let url;
try { url = buildProductUrl(args.input); } catch (error) { throw new ArgumentError(error.message); }
await gotoAmazon(page, url, 'cart product');
await assertAuthenticated(page);
await selectVariant(page, 'color', args.colour);
await selectVariant(page, 'size', args.size);
const selected = await page.evaluate(`
(() => ({
asin: (location.pathname.match(/\\/dp\\/([A-Z0-9]{10})/i)?.[1] || document.querySelector('#ASIN')?.value || '').toUpperCase(),
title: (document.querySelector('#productTitle')?.textContent || '').replace(/\\s+/g, ' ').trim(),
}))()
`);
if (!selected.asin || !selected.title) throw new CommandExecutionError('Amazon product selection could not be verified');
await page.evaluate(`
(() => document.querySelector('#add-to-cart-button')?.click())()
`);
await page.sleep(2.5);
const confirmed = await page.evaluate(`
(() => ({
url: location.href,
text: document.body?.innerText || '',
confirmation: Boolean(document.querySelector('#huc-v2-order-row, #attachDisplayAddBaseAlert')),
}))()
`);
if (!confirmed.confirmation && !/added to cart|added to your cart/i.test(confirmed.text)) {
throw new CommandExecutionError('Amazon.in did not confirm the item was added to cart');
}
return [{ status: 'added', asin: selected.asin, title: selected.title, size: args.size || '', colour: args.colour || '', action: 'Item added to the authenticated Amazon.in cart.' }];
},
});
58 changes: 58 additions & 0 deletions plugins/amazon-in/cart.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import { AuthRequiredError, CommandExecutionError } from '@agentrhq/webcmd/errors';
import { cli, Strategy } from '@agentrhq/webcmd/registry';
import { hasAmazonInAuthCookie } from './parsers.js';
import { gotoAmazon, SITE, DOMAIN } from './shared.js';

const CART_URL = 'https://www.amazon.in/gp/cart/view.html';

cli({
site: SITE,
name: 'cart',
access: 'read',
description: 'Read the authenticated Amazon.in cart',
domain: DOMAIN,
strategy: Strategy.UI,
browser: true,
navigateBefore: false,
siteSession: 'persistent',
freshPage: true,
args: [],
columns: ['asin', 'title', 'price', 'quantity', 'product_url'],
func: async (page) => {
await gotoAmazon(page, CART_URL, 'cart');
try {
await page.wait({ selector: '#sc-active-cart .sc-list-item', timeout: 20 });
} catch {
await page.sleep(1);
}
const cookies = await page.getCookies({ url: 'https://www.amazon.in/' });
if (!hasAmazonInAuthCookie(cookies.map((cookie) => cookie.name))) {
throw new AuthRequiredError(DOMAIN, 'Amazon.in login is required before reading the cart');
}
const payload = await page.evaluate(`
(() => {
const text = (node) => (node?.textContent || '').replace(/\\s+/g, ' ').trim();
const rows = [...document.querySelectorAll('#sc-active-cart .sc-list-item')].map((item) => {
const asin = item.getAttribute('data-asin') || item.querySelector('[data-asin]')?.getAttribute('data-asin') || '';
const title = text(item.querySelector('.sc-product-title') || item.querySelector('.a-truncate-cut, [data-name]'))
.replace(/Opens in a new tab/gi, '').trim();
const price = text(item.querySelector('.sc-price, .a-price .a-offscreen'));
const quantity = Number(item.querySelector('select[name^="quantity"], input[name^="quantity"]')?.value || 1);
return { asin, title, price, quantity };
}).filter((row) => row.asin && row.title);
return { rows, empty: /your amazon cart is empty|no items in your cart/i.test(document.body?.innerText || '') };
})()
`);
if (payload.rows.length > 0) {
return payload.rows.map((row) => ({
asin: row.asin,
title: row.title,
price: Number(row.price.replaceAll('₹', '').replaceAll(',', '')) || null,
quantity: Number.isInteger(row.quantity) && row.quantity > 0 ? row.quantity : 1,
product_url: `https://www.amazon.in/dp/${row.asin}`,
}));
}
if (payload.empty) return [];
throw new CommandExecutionError('Amazon.in cart exposed no recognizable items', 'The cart page may have changed or a login challenge may be visible.');
},
});
7 changes: 6 additions & 1 deletion plugins/amazon-in/parsers.js
Original file line number Diff line number Diff line change
Expand Up @@ -123,7 +123,7 @@ export function normalizeProductSnapshot(snapshot) {
throw new Error('Amazon product price is missing');
}
const discountMatch = cleanText(snapshot.discountText).match(/-?\s*(\d+(?:\.\d+)?)\s*%/);
return {
const result = {
asin,
title,
price,
Expand All @@ -135,6 +135,11 @@ export function normalizeProductSnapshot(snapshot) {
image_url: cleanText(snapshot.imageUrl),
product_url: `https://www.amazon.in/dp/${asin}`,
};
const sizes = Array.isArray(snapshot.availableSizes) ? snapshot.availableSizes.map(cleanText).filter(Boolean) : [];
const colours = Array.isArray(snapshot.availableColours) ? snapshot.availableColours.map(cleanText).filter(Boolean) : [];
if (sizes.length) result.available_sizes = sizes;
if (colours.length) result.available_colours = colours;
return result;
}

export function normalizeWishlistRows(listName, cards) {
Expand Down
13 changes: 12 additions & 1 deletion plugins/amazon-in/product.js
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ cli({
],
columns: [
'asin', 'title', 'price', 'mrp', 'discount', 'availability',
'size', 'colour', 'image_url', 'product_url',
'size', 'colour', 'available_sizes', 'available_colours', 'image_url', 'product_url',
],
func: async (page, args) => {
let url;
Expand All @@ -46,6 +46,15 @@ cli({
const text = (selector) => (document.querySelector(selector)?.textContent || '')
.replace(/\\s+/g, ' ').trim();
const image = document.querySelector('#landingImage');
const labels = (selector, colour) => [...document.querySelectorAll(selector)]
.filter((node) => {
const style = getComputedStyle(node);
const rect = node.getBoundingClientRect();
return rect.width > 0 && rect.height > 0 && style.display !== 'none' && style.visibility !== 'hidden' && !node.classList.contains('aok-hidden');
})
.map((node) => (colour ? (node.querySelector('img')?.alt || '') : node.textContent || '').replace(/\s+/g, ' ').trim())
.filter(Boolean)
.filter((value, index, values) => values.indexOf(value) === index);
const snapshot = {
href: location.href,
title: text('#productTitle'),
Expand All @@ -55,6 +64,8 @@ cli({
availabilityText: text('#availability'),
sizeText: text('#inline-twister-expanded-dimension-text-size_name, #variation_size_name .selection, #variation_size_name li.swatchSelect .a-button-text'),
colourText: text('#inline-twister-expanded-dimension-text-color_name, #variation_color_name .selection, #variation_color_name li.swatchSelect .a-button-text'),
availableSizes: labels('#inline-twister-expander-content-size_name span[id^="size_name_"]:not([id$="-announce"])', false),
availableColours: labels('#inline-twister-expander-content-color_name span[id^="color_name_"]:not([id$="-announce"])', true),
imageUrl: image?.getAttribute('data-old-hires') || image?.currentSrc || image?.src || '',
};
return snapshot;
Expand Down