diff --git a/plugins/amazon-in/README.md b/plugins/amazon-in/README.md index 74d985e..3cf3f1d 100644 --- a/plugins/amazon-in/README.md +++ b/plugins/amazon-in/README.md @@ -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 | diff --git a/plugins/amazon-in/auth.js b/plugins/amazon-in/auth.js index bfdd98e..7640fc8 100644 --- a/plugins/amazon-in/auth.js +++ b/plugins/amazon-in/auth.js @@ -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' }); + }, }); diff --git a/plugins/amazon-in/cart-add.js b/plugins/amazon-in/cart-add.js new file mode 100644 index 0000000..71d123e --- /dev/null +++ b/plugins/amazon-in/cart-add.js @@ -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.' }]; + }, +}); diff --git a/plugins/amazon-in/cart.js b/plugins/amazon-in/cart.js new file mode 100644 index 0000000..e9799ef --- /dev/null +++ b/plugins/amazon-in/cart.js @@ -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.'); + }, +}); diff --git a/plugins/amazon-in/parsers.js b/plugins/amazon-in/parsers.js index fa9354f..fe9b55f 100644 --- a/plugins/amazon-in/parsers.js +++ b/plugins/amazon-in/parsers.js @@ -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, @@ -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) { diff --git a/plugins/amazon-in/product.js b/plugins/amazon-in/product.js index 99dce3b..a5c1404 100644 --- a/plugins/amazon-in/product.js +++ b/plugins/amazon-in/product.js @@ -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; @@ -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'), @@ -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;