@agentrhq/webcmd 0.4.0 → 0.4.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +3 -0
- package/cli-manifest.json +278 -0
- package/clis/amazon-in/auth.js +44 -0
- package/clis/amazon-in/checkout-status.js +54 -0
- package/clis/amazon-in/checkout.js +479 -0
- package/clis/amazon-in/parsers.js +252 -0
- package/clis/amazon-in/parsers.test.js +230 -0
- package/clis/amazon-in/product.js +72 -0
- package/clis/amazon-in/search.js +76 -0
- package/clis/amazon-in/shared.js +40 -0
- package/clis/amazon-in/wishlist.js +98 -0
- package/dist/src/completion-shared.js +1 -1
- package/dist/src/docs-sync-review.js +13 -5
- package/dist/src/docs-sync-review.test.js +36 -0
- package/dist/src/generate-release-notes-cli.test.js +1 -1
- package/dist/src/hosted/client.d.ts +10 -0
- package/dist/src/hosted/client.js +36 -3
- package/dist/src/hosted/client.test.js +129 -21
- package/dist/src/hosted/manifest.test.js +2 -1
- package/dist/src/hosted/root-command-surface.test.js +7 -0
- package/dist/src/hosted/runner.js +66 -1
- package/dist/src/hosted/runner.test.js +102 -1
- package/dist/src/hosted/types.d.ts +5 -2
- package/dist/src/root-command-surface.js +10 -2
- package/hosted-contract.json +329 -1
- package/package.json +2 -2
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
export const cleanText = (value) =>
|
|
2
|
+
typeof value === 'string'
|
|
3
|
+
? value.replace(/\u00a0/g, ' ').replace(/\s+/g, ' ').trim()
|
|
4
|
+
: '';
|
|
5
|
+
|
|
6
|
+
export function classifyPageState(url, text) {
|
|
7
|
+
if (/\/(?:ap\/signin|signin)(?:[/?]|$)/i.test(url)) return 'login';
|
|
8
|
+
if (/enter the characters you see below|sorry, we just need to make sure you're not a robot/i.test(cleanText(text))) {
|
|
9
|
+
return 'robot';
|
|
10
|
+
}
|
|
11
|
+
return 'usable';
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function hasAmazonInAuthCookie(names) {
|
|
15
|
+
const cookies = new Set(names);
|
|
16
|
+
return cookies.has('at-acbin') || cookies.has('x-acbin');
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function extractAsin(input) {
|
|
20
|
+
const value = cleanText(input);
|
|
21
|
+
if (/^[A-Z0-9]{10}$/i.test(value)) return value.toUpperCase();
|
|
22
|
+
try {
|
|
23
|
+
const url = new URL(value);
|
|
24
|
+
if (!/(^|\.)amazon\.in$/i.test(url.hostname)) return null;
|
|
25
|
+
return url.pathname.match(/\/(?:dp|gp\/product)\/([A-Z0-9]{10})/i)?.[1]?.toUpperCase() ?? null;
|
|
26
|
+
} catch {
|
|
27
|
+
return null;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function buildProductUrl(input) {
|
|
32
|
+
const asin = extractAsin(input);
|
|
33
|
+
if (!asin) throw new RangeError('Expected an Amazon.in product URL or 10-character ASIN');
|
|
34
|
+
return `https://www.amazon.in/dp/${asin}`;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function parseMoney(text) {
|
|
38
|
+
const match = cleanText(text).match(/(?:₹|Rs\.?)\s*([\d,]+(?:\.\d{1,2})?)/i);
|
|
39
|
+
return match ? Number(match[1].replaceAll(',', '')) : null;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function parseCompactCount(text) {
|
|
43
|
+
const match = cleanText(text).match(/([\d,.]+)\s*([KM])?/i);
|
|
44
|
+
if (!match) return null;
|
|
45
|
+
const scale = match[2]?.toUpperCase() === 'M' ? 1_000_000 : match[2] ? 1_000 : 1;
|
|
46
|
+
return Math.round(Number(match[1].replaceAll(',', '')) * scale);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function validatePositiveInteger(value, name, maximum) {
|
|
50
|
+
const number = Number(value);
|
|
51
|
+
if (!Number.isInteger(number) || number < 1 || number > maximum) {
|
|
52
|
+
throw new RangeError(`${name} must be an integer from 1 to ${maximum}`);
|
|
53
|
+
}
|
|
54
|
+
return number;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function parseBoolean(value) {
|
|
58
|
+
if (typeof value === 'boolean') return value;
|
|
59
|
+
if (value === undefined || value === null || value === '') return false;
|
|
60
|
+
if (/^(true|1)$/i.test(String(value))) return true;
|
|
61
|
+
if (/^(false|0)$/i.test(String(value))) return false;
|
|
62
|
+
throw new RangeError('place-order must be true or false');
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function validatePriceBounds(minimum, maximum) {
|
|
66
|
+
const minPrice = minimum === undefined ? null : Number(minimum);
|
|
67
|
+
const maxPrice = maximum === undefined ? null : Number(maximum);
|
|
68
|
+
if (minPrice !== null && (!Number.isFinite(minPrice) || minPrice < 0)) {
|
|
69
|
+
throw new RangeError('minimum price must be zero or greater');
|
|
70
|
+
}
|
|
71
|
+
if (maxPrice !== null && (!Number.isFinite(maxPrice) || maxPrice < 0)) {
|
|
72
|
+
throw new RangeError('maximum price must be zero or greater');
|
|
73
|
+
}
|
|
74
|
+
if (minPrice !== null && maxPrice !== null && minPrice > maxPrice) {
|
|
75
|
+
throw new RangeError('minimum price cannot exceed maximum price');
|
|
76
|
+
}
|
|
77
|
+
return { minPrice, maxPrice };
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function buildSearchUrl(query, { minPrice, maxPrice }) {
|
|
81
|
+
const url = new URL('/s', 'https://www.amazon.in');
|
|
82
|
+
url.searchParams.set('k', cleanText(query));
|
|
83
|
+
if (minPrice !== null || maxPrice !== null) {
|
|
84
|
+
const low = Math.round((minPrice ?? 0) * 100);
|
|
85
|
+
const high = Math.round((maxPrice ?? 10_000_000) * 100);
|
|
86
|
+
url.searchParams.set('rh', `p_36:${low}-${high}`);
|
|
87
|
+
}
|
|
88
|
+
return url.href;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export function normalizeSearchCards(cards, { minPrice, maxPrice, limit }) {
|
|
92
|
+
return cards
|
|
93
|
+
.map((card) => {
|
|
94
|
+
const price = parseMoney(card.cardPriceText);
|
|
95
|
+
if (!card.cardAsin || !cleanText(card.cardTitle) || price === null) return null;
|
|
96
|
+
if (minPrice !== null && price < minPrice) return null;
|
|
97
|
+
if (maxPrice !== null && price > maxPrice) return null;
|
|
98
|
+
return {
|
|
99
|
+
rank: 0,
|
|
100
|
+
asin: card.cardAsin,
|
|
101
|
+
title: cleanText(card.cardTitle),
|
|
102
|
+
price,
|
|
103
|
+
mrp: parseMoney(card.cardMrpText),
|
|
104
|
+
rating: Number(cleanText(card.cardRatingText).match(/[\d.]+/)?.[0]) || null,
|
|
105
|
+
review_count: parseCompactCount(card.cardReviewText),
|
|
106
|
+
image_url: cleanText(card.cardImageUrl),
|
|
107
|
+
product_url: `https://www.amazon.in/dp/${card.cardAsin}`,
|
|
108
|
+
is_sponsored: card.cardSponsored === true,
|
|
109
|
+
};
|
|
110
|
+
})
|
|
111
|
+
.filter(Boolean)
|
|
112
|
+
.slice(0, limit)
|
|
113
|
+
.map((row, index) => ({ ...row, rank: index + 1 }));
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export function normalizeProductSnapshot(snapshot) {
|
|
117
|
+
const asin = extractAsin(snapshot.href);
|
|
118
|
+
const title = cleanText(snapshot.title);
|
|
119
|
+
const availability = cleanText(snapshot.availabilityText);
|
|
120
|
+
if (!asin || !title || !availability) throw new Error('Amazon product details are incomplete');
|
|
121
|
+
const price = parseMoney(snapshot.priceText);
|
|
122
|
+
if (price === null && !/unavailable|out of stock|currently unavailable/i.test(availability)) {
|
|
123
|
+
throw new Error('Amazon product price is missing');
|
|
124
|
+
}
|
|
125
|
+
const discountMatch = cleanText(snapshot.discountText).match(/-?\s*(\d+(?:\.\d+)?)\s*%/);
|
|
126
|
+
return {
|
|
127
|
+
asin,
|
|
128
|
+
title,
|
|
129
|
+
price,
|
|
130
|
+
mrp: parseMoney(snapshot.mrpText),
|
|
131
|
+
discount: discountMatch ? Number(discountMatch[1]) : null,
|
|
132
|
+
availability,
|
|
133
|
+
size: cleanText(snapshot.sizeText),
|
|
134
|
+
colour: cleanText(snapshot.colourText),
|
|
135
|
+
image_url: cleanText(snapshot.imageUrl),
|
|
136
|
+
product_url: `https://www.amazon.in/dp/${asin}`,
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export function normalizeWishlistRows(listName, cards) {
|
|
141
|
+
return cards.map((card) => {
|
|
142
|
+
const asin = extractAsin(card.cardHref);
|
|
143
|
+
const title = cleanText(card.cardTitle);
|
|
144
|
+
const itemId = cleanText(card.cardItemId);
|
|
145
|
+
const availability = cleanText(card.cardAvailabilityText);
|
|
146
|
+
const price = parseMoney(card.cardPriceText);
|
|
147
|
+
if (!asin || !title || !itemId) throw new Error('Amazon wishlist item details are incomplete');
|
|
148
|
+
if (price === null && !/unavailable|out of stock|currently unavailable/i.test(availability)) {
|
|
149
|
+
throw new Error(`Amazon wishlist price is missing for ${asin}`);
|
|
150
|
+
}
|
|
151
|
+
return {
|
|
152
|
+
list_name: cleanText(listName),
|
|
153
|
+
item_id: itemId,
|
|
154
|
+
asin,
|
|
155
|
+
title,
|
|
156
|
+
price,
|
|
157
|
+
mrp: parseMoney(card.cardMrpText),
|
|
158
|
+
availability,
|
|
159
|
+
size: cleanText(card.cardSizeText),
|
|
160
|
+
colour: cleanText(card.cardColourText),
|
|
161
|
+
image_url: cleanText(card.cardImageUrl),
|
|
162
|
+
product_url: `https://www.amazon.in/dp/${asin}`,
|
|
163
|
+
};
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
export function validateCheckoutArgs(args) {
|
|
168
|
+
const quantity = validatePositiveInteger(args.quantity ?? 1, 'quantity', 10);
|
|
169
|
+
const payment = cleanText(args.payment);
|
|
170
|
+
if (!['upi', 'saved-card', 'new-card', 'cod'].includes(payment)) {
|
|
171
|
+
throw new RangeError('payment must be upi, saved-card, new-card, or cod');
|
|
172
|
+
}
|
|
173
|
+
const cardLast4 = cleanText(args.cardLast4);
|
|
174
|
+
if (payment === 'saved-card' && !/^\d{4}$/.test(cardLast4)) {
|
|
175
|
+
throw new RangeError('card-last4 must contain exactly four digits for saved-card');
|
|
176
|
+
}
|
|
177
|
+
return {
|
|
178
|
+
quantity,
|
|
179
|
+
payment,
|
|
180
|
+
cardLast4,
|
|
181
|
+
size: cleanText(args.size),
|
|
182
|
+
colour: cleanText(args.colour),
|
|
183
|
+
placeOrder: parseBoolean(args.placeOrder),
|
|
184
|
+
};
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
export function normalizeCheckoutReview(snapshot) {
|
|
188
|
+
const itemPrice = parseMoney(snapshot.itemPriceText);
|
|
189
|
+
const total = parseMoney(snapshot.totalText);
|
|
190
|
+
if (itemPrice === null || total === null) throw new Error('Checkout item price or total is missing');
|
|
191
|
+
const deliveryFee = (parseMoney(snapshot.deliveryFeeText) ?? 0)
|
|
192
|
+
- (parseMoney(snapshot.deliveryDiscountText) ?? 0);
|
|
193
|
+
return {
|
|
194
|
+
itemPrice,
|
|
195
|
+
deliveryFee,
|
|
196
|
+
marketplaceFee: parseMoney(snapshot.marketplaceFeeText) ?? 0,
|
|
197
|
+
total,
|
|
198
|
+
quantity: Number(snapshot.quantity),
|
|
199
|
+
};
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
export function totalsAreConsistent(review) {
|
|
203
|
+
const expected = review.itemPrice * review.quantity + review.deliveryFee + review.marketplaceFee;
|
|
204
|
+
return Math.abs(expected - review.total) <= 0.011;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
export function classifyCheckoutSnapshot(snapshot) {
|
|
208
|
+
const url = cleanText(snapshot.url);
|
|
209
|
+
const text = cleanText(snapshot.text);
|
|
210
|
+
const reviewReady = /\/checkout\/p\/.+\/spc/i.test(url) && /Order Total:/i.test(text);
|
|
211
|
+
const awaitingPayment = !reviewReady && (
|
|
212
|
+
/aips\/process-payment/i.test(url)
|
|
213
|
+
|| /QR code|enter (?:your )?CVV|one.time password|3-D Secure/i.test(text)
|
|
214
|
+
);
|
|
215
|
+
const hasPaymentText = Object.hasOwn(snapshot, 'paymentText');
|
|
216
|
+
const explicitPaymentText = hasPaymentText ? cleanText(snapshot.paymentText) : '';
|
|
217
|
+
const paymentText = hasPaymentText
|
|
218
|
+
? explicitPaymentText || (awaitingPayment ? text : '')
|
|
219
|
+
: text;
|
|
220
|
+
const paymentMethod = /UPI|QR code/i.test(paymentText)
|
|
221
|
+
? 'upi'
|
|
222
|
+
: /Cash on Delivery|Pay on Delivery/i.test(paymentText)
|
|
223
|
+
? 'cod'
|
|
224
|
+
: /ending in|credit card|debit card|CVV|3-D Secure/i.test(paymentText)
|
|
225
|
+
? 'card'
|
|
226
|
+
: '';
|
|
227
|
+
const base = {
|
|
228
|
+
order_id: text.match(/\b\d{3}-\d{7}-\d{7}\b/)?.[0] ?? '',
|
|
229
|
+
total: parseMoney(text),
|
|
230
|
+
payment_method: paymentMethod,
|
|
231
|
+
action: '',
|
|
232
|
+
};
|
|
233
|
+
if (/\/(?:thankyou|buy\/thankyou)\//i.test(url) && /order (?:placed|confirmed)|thank you/i.test(text)) {
|
|
234
|
+
return { status: 'ordered', ...base };
|
|
235
|
+
}
|
|
236
|
+
if (/payment (?:failed|declined|unsuccessful)|transaction failed/i.test(text)) {
|
|
237
|
+
return { status: 'failed', ...base, action: 'Choose a payment method in the browser; do not retry a charge automatically.' };
|
|
238
|
+
}
|
|
239
|
+
if (/(?:QR|session|payment link).{0,30}expired|expired.{0,30}(?:QR|session|payment)/i.test(text)) {
|
|
240
|
+
return { status: 'expired', ...base, action: 'Return to checkout and create a new payment attempt.' };
|
|
241
|
+
}
|
|
242
|
+
if (reviewReady) {
|
|
243
|
+
return { status: 'review_ready', ...base };
|
|
244
|
+
}
|
|
245
|
+
if (awaitingPayment) {
|
|
246
|
+
return { status: 'awaiting_payment', ...base, action: 'Complete payment in the opened browser, then run checkout-status again.' };
|
|
247
|
+
}
|
|
248
|
+
if (/\/ap\/signin/i.test(url) || /enter your (?:email|mobile number)|sign in/i.test(text)) {
|
|
249
|
+
return { status: 'login_required', ...base, action: 'Sign in in the opened browser, then run checkout-status again.' };
|
|
250
|
+
}
|
|
251
|
+
throw new Error('Amazon checkout state is not recognized');
|
|
252
|
+
}
|
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { __test__ as checkoutTest } from './checkout.js';
|
|
3
|
+
import {
|
|
4
|
+
buildProductUrl,
|
|
5
|
+
classifyCheckoutSnapshot,
|
|
6
|
+
classifyPageState,
|
|
7
|
+
extractAsin,
|
|
8
|
+
hasAmazonInAuthCookie,
|
|
9
|
+
normalizeCheckoutReview,
|
|
10
|
+
normalizeProductSnapshot,
|
|
11
|
+
normalizeSearchCards,
|
|
12
|
+
normalizeWishlistRows,
|
|
13
|
+
parseCompactCount,
|
|
14
|
+
parseMoney,
|
|
15
|
+
totalsAreConsistent,
|
|
16
|
+
validateCheckoutArgs,
|
|
17
|
+
validatePositiveInteger,
|
|
18
|
+
validatePriceBounds,
|
|
19
|
+
} from './parsers.js';
|
|
20
|
+
|
|
21
|
+
describe('amazon-in parsers', () => {
|
|
22
|
+
it('normalizes ASINs and parses Indian prices and counts', () => {
|
|
23
|
+
expect(extractAsin('https://www.amazon.in/dp/B0D2QL339Z?psc=1')).toBe('B0D2QL339Z');
|
|
24
|
+
expect(buildProductUrl('B0D2QL339Z')).toBe('https://www.amazon.in/dp/B0D2QL339Z');
|
|
25
|
+
expect(extractAsin('https://amazon.com/dp/B0D2QL339Z')).toBeNull();
|
|
26
|
+
expect(parseMoney('₹1,499.00')).toBe(1499);
|
|
27
|
+
expect(parseMoney('Unavailable')).toBeNull();
|
|
28
|
+
expect(parseCompactCount('39.7K')).toBe(39700);
|
|
29
|
+
expect(parseCompactCount('3,564 ratings')).toBe(3564);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it('validates bounds without silently clamping', () => {
|
|
33
|
+
expect(validatePriceBounds('300', '500')).toEqual({ minPrice: 300, maxPrice: 500 });
|
|
34
|
+
expect(() => validatePriceBounds('500', '300')).toThrow(/minimum price/i);
|
|
35
|
+
expect(validatePositiveInteger('20', 'limit', 50)).toBe(20);
|
|
36
|
+
expect(() => validatePositiveInteger('51', 'limit', 50)).toThrow(/1 to 50/i);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it('classifies login and robot pages before DOM parsing', () => {
|
|
40
|
+
expect(classifyPageState('https://www.amazon.in/ap/signin', '')).toBe('login');
|
|
41
|
+
expect(classifyPageState('https://www.amazon.in/', 'Enter the characters you see below')).toBe('robot');
|
|
42
|
+
expect(classifyPageState('https://www.amazon.in/s?k=shirt', 'Results')).toBe('usable');
|
|
43
|
+
expect(hasAmazonInAuthCookie(['session-id', 'x-acbin'])).toBe(true);
|
|
44
|
+
expect(hasAmazonInAuthCookie(['session-id', 'i18n-prefs'])).toBe(false);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it('filters search cards inclusively and preserves images', () => {
|
|
48
|
+
const rows = normalizeSearchCards([
|
|
49
|
+
{
|
|
50
|
+
cardAsin: 'B000000001',
|
|
51
|
+
cardTitle: 'Low',
|
|
52
|
+
cardPriceText: '₹299',
|
|
53
|
+
cardImageUrl: 'https://m.media-amazon.com/low.jpg',
|
|
54
|
+
},
|
|
55
|
+
{
|
|
56
|
+
cardAsin: 'B000000002',
|
|
57
|
+
cardTitle: 'Match',
|
|
58
|
+
cardPriceText: '₹500',
|
|
59
|
+
cardImageUrl: 'https://m.media-amazon.com/match.jpg',
|
|
60
|
+
},
|
|
61
|
+
{
|
|
62
|
+
cardAsin: 'B000000003',
|
|
63
|
+
cardTitle: 'No price',
|
|
64
|
+
cardPriceText: '',
|
|
65
|
+
cardImageUrl: '',
|
|
66
|
+
},
|
|
67
|
+
], { minPrice: 300, maxPrice: 500, limit: 10 });
|
|
68
|
+
expect(rows.map((row) => row.asin)).toEqual(['B000000002']);
|
|
69
|
+
expect(rows[0].image_url).toBe('https://m.media-amazon.com/match.jpg');
|
|
70
|
+
expect(rows[0].rank).toBe(1);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it('normalizes selected product and wishlist variants', () => {
|
|
74
|
+
expect(normalizeProductSnapshot({
|
|
75
|
+
href: 'https://www.amazon.in/dp/B0D2QL339Z?th=1&psc=1',
|
|
76
|
+
title: 'Besix shirt',
|
|
77
|
+
priceText: '₹395.00',
|
|
78
|
+
mrpText: '₹1,499.00',
|
|
79
|
+
discountText: '-74%',
|
|
80
|
+
availabilityText: 'In stock',
|
|
81
|
+
sizeText: 'L',
|
|
82
|
+
colourText: 'Red',
|
|
83
|
+
imageUrl: 'https://m.media-amazon.com/image.jpg',
|
|
84
|
+
})).toEqual({
|
|
85
|
+
asin: 'B0D2QL339Z',
|
|
86
|
+
title: 'Besix shirt',
|
|
87
|
+
price: 395,
|
|
88
|
+
mrp: 1499,
|
|
89
|
+
discount: 74,
|
|
90
|
+
availability: 'In stock',
|
|
91
|
+
size: 'L',
|
|
92
|
+
colour: 'Red',
|
|
93
|
+
image_url: 'https://m.media-amazon.com/image.jpg',
|
|
94
|
+
product_url: 'https://www.amazon.in/dp/B0D2QL339Z',
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
const [row] = normalizeWishlistRows('Shopping List', [{
|
|
98
|
+
cardItemId: 'item-1',
|
|
99
|
+
cardHref: 'https://www.amazon.in/dp/B0D2QL339Z',
|
|
100
|
+
cardTitle: 'Besix shirt',
|
|
101
|
+
cardPriceText: '₹395.00',
|
|
102
|
+
cardMrpText: '₹1,499.00',
|
|
103
|
+
cardAvailabilityText: 'In stock',
|
|
104
|
+
cardSizeText: 'L',
|
|
105
|
+
cardColourText: 'Red',
|
|
106
|
+
cardImageUrl: 'https://m.media-amazon.com/image.jpg',
|
|
107
|
+
}]);
|
|
108
|
+
expect(row).toMatchObject({
|
|
109
|
+
asin: 'B0D2QL339Z',
|
|
110
|
+
price: 395,
|
|
111
|
+
list_name: 'Shopping List',
|
|
112
|
+
});
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it('validates checkout payment selectors and totals', () => {
|
|
116
|
+
expect(validateCheckoutArgs({
|
|
117
|
+
quantity: 1,
|
|
118
|
+
payment: 'saved-card',
|
|
119
|
+
cardLast4: '6764',
|
|
120
|
+
placeOrder: false,
|
|
121
|
+
}).payment).toBe('saved-card');
|
|
122
|
+
expect(validateCheckoutArgs({ quantity: 1, payment: 'upi' }).placeOrder).toBe(false);
|
|
123
|
+
expect(() => validateCheckoutArgs({
|
|
124
|
+
quantity: 1,
|
|
125
|
+
payment: 'saved-card',
|
|
126
|
+
cardLast4: '',
|
|
127
|
+
})).toThrow(/card-last4/i);
|
|
128
|
+
expect(() => validateCheckoutArgs({
|
|
129
|
+
quantity: 1,
|
|
130
|
+
payment: 'card-number',
|
|
131
|
+
})).toThrow(/upi, saved-card, new-card, or cod/i);
|
|
132
|
+
|
|
133
|
+
const review = normalizeCheckoutReview({
|
|
134
|
+
itemPriceText: '₹395',
|
|
135
|
+
deliveryFeeText: '₹40',
|
|
136
|
+
deliveryDiscountText: '-₹40',
|
|
137
|
+
marketplaceFeeText: '₹5',
|
|
138
|
+
totalText: '₹400',
|
|
139
|
+
quantity: 1,
|
|
140
|
+
});
|
|
141
|
+
expect(review.deliveryFee).toBe(0);
|
|
142
|
+
expect(totalsAreConsistent(review)).toBe(true);
|
|
143
|
+
expect(totalsAreConsistent({ ...review, total: 500 })).toBe(false);
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
it('classifies checkout state without submitting', () => {
|
|
147
|
+
expect(classifyCheckoutSnapshot({
|
|
148
|
+
url: 'https://www.amazon.in/aips/process-payment',
|
|
149
|
+
text: 'Complete your payment Payment of ₹ 400.00 QR code is valid',
|
|
150
|
+
})).toMatchObject({ status: 'awaiting_payment', payment_method: 'upi', total: 400 });
|
|
151
|
+
expect(classifyCheckoutSnapshot({
|
|
152
|
+
url: 'https://www.amazon.in/gp/buy/thankyou/handlers/display.html',
|
|
153
|
+
text: 'Order placed, thank you',
|
|
154
|
+
}).status).toBe('ordered');
|
|
155
|
+
expect(classifyCheckoutSnapshot({
|
|
156
|
+
url: 'https://www.amazon.in/checkout/p/example/spc',
|
|
157
|
+
text: 'Order Total: ₹400 Pay with UPI',
|
|
158
|
+
}).status).toBe('review_ready');
|
|
159
|
+
expect(classifyCheckoutSnapshot({
|
|
160
|
+
url: 'https://www.amazon.in/checkout/p/example/spc',
|
|
161
|
+
paymentText: 'Pay by scanning the QR code',
|
|
162
|
+
text: 'Order Total: ₹400 A UPI QR code will appear on the next page',
|
|
163
|
+
}).status).toBe('review_ready');
|
|
164
|
+
expect(classifyCheckoutSnapshot({
|
|
165
|
+
url: 'https://www.amazon.in/checkout/p/example/spc',
|
|
166
|
+
paymentText: 'Visa ending in 6764',
|
|
167
|
+
text: 'Order Total: ₹400 Other methods: Pay with UPI',
|
|
168
|
+
}).payment_method).toBe('card');
|
|
169
|
+
expect(classifyCheckoutSnapshot({
|
|
170
|
+
url: 'https://www.amazon.in/aips/process-payment',
|
|
171
|
+
paymentText: '',
|
|
172
|
+
text: 'Complete your payment Payment of ₹400 Scan the QR code',
|
|
173
|
+
}).payment_method).toBe('upi');
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
it('hands secret entry to the browser before trying Continue', async () => {
|
|
177
|
+
const page = {
|
|
178
|
+
wait: () => { throw new Error('must not wait'); },
|
|
179
|
+
click: () => { throw new Error('must not click Continue'); },
|
|
180
|
+
evaluate: () => { throw new Error('must not inspect new-card secrets'); },
|
|
181
|
+
};
|
|
182
|
+
const row = await checkoutTest.continueAfterPaymentSelection(
|
|
183
|
+
page,
|
|
184
|
+
{ payment: 'new-card' },
|
|
185
|
+
{ asin: 'B0D2QL339Z', title: 'Shirt', size: 'L', colour: 'Red' },
|
|
186
|
+
1,
|
|
187
|
+
);
|
|
188
|
+
expect(row).toMatchObject({ status: 'action_required', payment_method: 'new-card' });
|
|
189
|
+
|
|
190
|
+
let clicks = 0;
|
|
191
|
+
const savedCardRow = await checkoutTest.continueAfterPaymentSelection({
|
|
192
|
+
evaluate: async () => ({ needsSecret: true, continueEnabled: false }),
|
|
193
|
+
click: async () => { clicks += 1; },
|
|
194
|
+
sleep: () => { throw new Error('must not sleep after finding CVV'); },
|
|
195
|
+
}, {
|
|
196
|
+
payment: 'saved-card',
|
|
197
|
+
}, {
|
|
198
|
+
asin: 'B0D2QL339Z',
|
|
199
|
+
title: 'Shirt',
|
|
200
|
+
size: 'L',
|
|
201
|
+
colour: 'Red',
|
|
202
|
+
}, 1);
|
|
203
|
+
expect(savedCardRow).toMatchObject({ status: 'action_required', payment_method: 'saved-card' });
|
|
204
|
+
expect(clicks).toBe(0);
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
it('requires one matching line item and never clicks Place Order by default', async () => {
|
|
208
|
+
expect(() => checkoutTest.assertSingleLineItem({
|
|
209
|
+
itemCount: 2,
|
|
210
|
+
asin: 'B0D2QL339Z',
|
|
211
|
+
quantity: 1,
|
|
212
|
+
}, {
|
|
213
|
+
asin: 'B0D2QL339Z',
|
|
214
|
+
}, {
|
|
215
|
+
quantity: 1,
|
|
216
|
+
})).toThrow(/exactly one checkout line item/i);
|
|
217
|
+
|
|
218
|
+
let placements = 0;
|
|
219
|
+
const page = {
|
|
220
|
+
evaluate: async () => {
|
|
221
|
+
placements += 1;
|
|
222
|
+
return { clicked: true, matches: 1 };
|
|
223
|
+
},
|
|
224
|
+
};
|
|
225
|
+
expect(await checkoutTest.submitOrder(page, false)).toBe(false);
|
|
226
|
+
expect(placements).toBe(0);
|
|
227
|
+
expect(await checkoutTest.submitOrder(page, true)).toBe(true);
|
|
228
|
+
expect(placements).toBe(1);
|
|
229
|
+
});
|
|
230
|
+
});
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { ArgumentError, CommandExecutionError } from '@agentrhq/webcmd/errors';
|
|
2
|
+
import { cli, Strategy } from '@agentrhq/webcmd/registry';
|
|
3
|
+
import { buildProductUrl, normalizeProductSnapshot } from './parsers.js';
|
|
4
|
+
import { gotoAmazon, SITE } from './shared.js';
|
|
5
|
+
|
|
6
|
+
cli({
|
|
7
|
+
site: SITE,
|
|
8
|
+
name: 'product',
|
|
9
|
+
access: 'read',
|
|
10
|
+
description: 'Fetch the current Amazon.in price and selected product variant',
|
|
11
|
+
domain: 'amazon.in',
|
|
12
|
+
strategy: Strategy.UI,
|
|
13
|
+
browser: true,
|
|
14
|
+
navigateBefore: false,
|
|
15
|
+
siteSession: 'persistent',
|
|
16
|
+
args: [
|
|
17
|
+
{
|
|
18
|
+
name: 'input',
|
|
19
|
+
required: true,
|
|
20
|
+
positional: true,
|
|
21
|
+
help: 'Amazon.in product URL or ASIN',
|
|
22
|
+
},
|
|
23
|
+
],
|
|
24
|
+
columns: [
|
|
25
|
+
'asin', 'title', 'price', 'mrp', 'discount', 'availability',
|
|
26
|
+
'size', 'colour', 'image_url', 'product_url',
|
|
27
|
+
],
|
|
28
|
+
func: async (page, args) => {
|
|
29
|
+
let url;
|
|
30
|
+
try {
|
|
31
|
+
url = buildProductUrl(args.input);
|
|
32
|
+
} catch (error) {
|
|
33
|
+
throw new ArgumentError(error.message);
|
|
34
|
+
}
|
|
35
|
+
await gotoAmazon(page, url, 'product page');
|
|
36
|
+
try {
|
|
37
|
+
await page.wait({ selector: '#productTitle', timeout: 15 });
|
|
38
|
+
} catch {
|
|
39
|
+
throw new CommandExecutionError(
|
|
40
|
+
'Amazon.in product title did not appear',
|
|
41
|
+
'The product may be unavailable or the page layout may have changed.',
|
|
42
|
+
);
|
|
43
|
+
}
|
|
44
|
+
const snapshot = await page.evaluate(`
|
|
45
|
+
(() => {
|
|
46
|
+
const text = (selector) => (document.querySelector(selector)?.textContent || '')
|
|
47
|
+
.replace(/\\s+/g, ' ').trim();
|
|
48
|
+
const image = document.querySelector('#landingImage');
|
|
49
|
+
const snapshot = {
|
|
50
|
+
href: location.href,
|
|
51
|
+
title: text('#productTitle'),
|
|
52
|
+
priceText: text('#corePrice_feature_div .a-price:not(.a-text-price) .a-offscreen, #priceblock_ourprice, #priceblock_dealprice'),
|
|
53
|
+
mrpText: text('#corePrice_feature_div .a-price.a-text-price .a-offscreen, .basisPrice .a-offscreen'),
|
|
54
|
+
discountText: text('#corePrice_feature_div .savingsPercentage, .savingsPercentage'),
|
|
55
|
+
availabilityText: text('#availability'),
|
|
56
|
+
sizeText: text('#inline-twister-expanded-dimension-text-size_name, #variation_size_name .selection, #variation_size_name li.swatchSelect .a-button-text'),
|
|
57
|
+
colourText: text('#inline-twister-expanded-dimension-text-color_name, #variation_color_name .selection, #variation_color_name li.swatchSelect .a-button-text'),
|
|
58
|
+
imageUrl: image?.getAttribute('data-old-hires') || image?.currentSrc || image?.src || '',
|
|
59
|
+
};
|
|
60
|
+
return snapshot;
|
|
61
|
+
})()
|
|
62
|
+
`);
|
|
63
|
+
try {
|
|
64
|
+
return [normalizeProductSnapshot(snapshot)];
|
|
65
|
+
} catch (error) {
|
|
66
|
+
throw new CommandExecutionError(
|
|
67
|
+
`Amazon.in product details could not be normalized: ${error.message}`,
|
|
68
|
+
'Check the visible product page for an unavailable item or changed layout.',
|
|
69
|
+
);
|
|
70
|
+
}
|
|
71
|
+
},
|
|
72
|
+
});
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import {
|
|
2
|
+
ArgumentError,
|
|
3
|
+
CommandExecutionError,
|
|
4
|
+
EmptyResultError,
|
|
5
|
+
} from '@agentrhq/webcmd/errors';
|
|
6
|
+
import { cli, Strategy } from '@agentrhq/webcmd/registry';
|
|
7
|
+
import {
|
|
8
|
+
buildSearchUrl,
|
|
9
|
+
cleanText,
|
|
10
|
+
normalizeSearchCards,
|
|
11
|
+
validatePositiveInteger,
|
|
12
|
+
validatePriceBounds,
|
|
13
|
+
} from './parsers.js';
|
|
14
|
+
import { argumentValue, gotoAmazon, SITE } from './shared.js';
|
|
15
|
+
|
|
16
|
+
cli({
|
|
17
|
+
site: SITE,
|
|
18
|
+
name: 'search',
|
|
19
|
+
access: 'read',
|
|
20
|
+
description: 'Search Amazon.in products with inclusive INR price bounds and images',
|
|
21
|
+
domain: 'amazon.in',
|
|
22
|
+
strategy: Strategy.UI,
|
|
23
|
+
browser: true,
|
|
24
|
+
navigateBefore: false,
|
|
25
|
+
siteSession: 'persistent',
|
|
26
|
+
args: [
|
|
27
|
+
{ name: 'query', required: true, positional: true, help: 'Product search query' },
|
|
28
|
+
{ name: 'min-price', type: 'number', help: 'Inclusive minimum price in rupees' },
|
|
29
|
+
{ name: 'max-price', type: 'number', help: 'Inclusive maximum price in rupees' },
|
|
30
|
+
{ name: 'limit', type: 'int', default: 20, help: 'Maximum results (1-50)' },
|
|
31
|
+
],
|
|
32
|
+
columns: [
|
|
33
|
+
'rank', 'asin', 'title', 'price', 'mrp', 'rating',
|
|
34
|
+
'review_count', 'image_url', 'product_url', 'is_sponsored',
|
|
35
|
+
],
|
|
36
|
+
func: async (page, args) => {
|
|
37
|
+
const query = cleanText(args.query);
|
|
38
|
+
if (!query) throw new ArgumentError('query must not be empty');
|
|
39
|
+
const bounds = argumentValue(() => validatePriceBounds(args['min-price'], args['max-price']));
|
|
40
|
+
const limit = argumentValue(() => validatePositiveInteger(args.limit ?? 20, 'limit', 50));
|
|
41
|
+
await gotoAmazon(page, buildSearchUrl(query, bounds), 'product search');
|
|
42
|
+
|
|
43
|
+
const payload = await page.evaluate(`
|
|
44
|
+
(() => {
|
|
45
|
+
const text = (node) => (node?.textContent || '').replace(/\\s+/g, ' ').trim();
|
|
46
|
+
const cards = [...document.querySelectorAll('[data-component-type="s-search-result"]')]
|
|
47
|
+
.map((card) => ({
|
|
48
|
+
cardAsin: (card.getAttribute('data-asin') || '').trim().toUpperCase(),
|
|
49
|
+
cardTitle: text(card.querySelector('a.a-text-normal.s-line-clamp-2')),
|
|
50
|
+
cardPriceText: text(card.querySelector('.a-price:not(.a-text-price) .a-offscreen, .a-price .a-offscreen')),
|
|
51
|
+
cardMrpText: text(card.querySelector('.a-price.a-text-price .a-offscreen')),
|
|
52
|
+
cardRatingText: text(card.querySelector('[aria-label*="out of 5 stars"], .a-icon-alt')),
|
|
53
|
+
cardReviewText: text(card.querySelector('a[href*="#customerReviews"], [aria-label*="ratings"]')),
|
|
54
|
+
cardImageUrl: card.querySelector('img.s-image')?.currentSrc || card.querySelector('img.s-image')?.src || '',
|
|
55
|
+
cardSponsored: /sponsored/i.test(text(card.querySelector('.puis-sponsored-label-text, [data-component-type="sp-sponsored-result"]'))),
|
|
56
|
+
}));
|
|
57
|
+
return {
|
|
58
|
+
cards,
|
|
59
|
+
noResults: /no results for|did not match any products/i.test(document.body?.innerText || ''),
|
|
60
|
+
};
|
|
61
|
+
})()
|
|
62
|
+
`);
|
|
63
|
+
if (!payload || !Array.isArray(payload.cards)) {
|
|
64
|
+
throw new CommandExecutionError('Amazon.in search returned an unsupported page shape');
|
|
65
|
+
}
|
|
66
|
+
const rows = normalizeSearchCards(payload.cards, { ...bounds, limit });
|
|
67
|
+
if (rows.length === 0) {
|
|
68
|
+
if (payload.noResults || payload.cards.length > 0) throw new EmptyResultError('amazon-in search');
|
|
69
|
+
throw new CommandExecutionError(
|
|
70
|
+
'Amazon.in search exposed no result cards',
|
|
71
|
+
'The page layout may have changed or a robot challenge may be visible.',
|
|
72
|
+
);
|
|
73
|
+
}
|
|
74
|
+
return rows;
|
|
75
|
+
},
|
|
76
|
+
});
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import {
|
|
2
|
+
ArgumentError,
|
|
3
|
+
AuthRequiredError,
|
|
4
|
+
CommandExecutionError,
|
|
5
|
+
} from '@agentrhq/webcmd/errors';
|
|
6
|
+
import { classifyPageState } from './parsers.js';
|
|
7
|
+
|
|
8
|
+
export const SITE = 'amazon-in';
|
|
9
|
+
export const DOMAIN = 'amazon.in';
|
|
10
|
+
export const HOME_URL = 'https://www.amazon.in/';
|
|
11
|
+
export const WISHLIST_URL = 'https://www.amazon.in/hz/wishlist/ls';
|
|
12
|
+
|
|
13
|
+
export function argumentValue(fn) {
|
|
14
|
+
try {
|
|
15
|
+
return fn();
|
|
16
|
+
} catch (error) {
|
|
17
|
+
if (error instanceof RangeError) throw new ArgumentError(error.message);
|
|
18
|
+
throw error;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export async function assertUsablePage(page, context) {
|
|
23
|
+
const snapshot = await page.evaluate(`
|
|
24
|
+
(() => ({ url: location.href, text: document.body?.innerText || '' }))()
|
|
25
|
+
`);
|
|
26
|
+
const state = classifyPageState(snapshot.url, snapshot.text);
|
|
27
|
+
if (state === 'login') throw new AuthRequiredError(DOMAIN);
|
|
28
|
+
if (state === 'robot') {
|
|
29
|
+
throw new CommandExecutionError(
|
|
30
|
+
`Amazon robot check blocked ${context}`,
|
|
31
|
+
'Complete the visible challenge in the Webcmd browser, then retry.',
|
|
32
|
+
);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export async function gotoAmazon(page, url, context) {
|
|
37
|
+
await page.goto(url, { waitUntil: 'load' });
|
|
38
|
+
await page.wait(2);
|
|
39
|
+
await assertUsablePage(page, context);
|
|
40
|
+
}
|