Purpose
Search amazon.com for products matching a query, applying any of the filters Amazon's
search UI exposes (department, brand, customer-review rating, price range, deals,
condition, seller, delivery speed, sort order, pagination), and return the matching
results as structured JSON. For each product it returns ASIN, title, primary image +
thumbnails, current/list price + discount %, rating (stars + review count), Prime /
sponsored / badge flags, and the canonical /dp/{ASIN} URL, plus the region-wide
totalResultCount from the results header. Read-only — it never adds to cart,
buys, subscribes, or signs in.
When to Use
- "Search Amazon for
<query>" with or without filters, and return the result list. - Price/availability monitoring across a filtered query (e.g. "wireless keyboards under $50, 4 stars & up, sorted cheapest first").
- Resolving a free-form query, a keyword+department, a full
amazon.com/s?...URL, a category-browse intent ("Bestsellers in Coffee"), or a list of ASINs into structured product records. - Anywhere you'd otherwise scrape Amazon search HTML — this documents the exact query-string filter encodings and a DOM extractor that survives Amazon's layout.
Workflow
Amazon search results are client-rendered behind aggressive bot fingerprinting. There is
no unauthenticated product-listing JSON endpoint, and a plain HTTP GET /s (even via
residential proxy) typically returns a 503 interstitial or a Robot Check. The reliable path
is browserless_agent with a residential proxy that loads the real /s page, extracted via
an evaluate expression — never snapshot (see Gotchas).
This is one long-running browser session you drive across as many browserless_agent calls
as you want — not a one-shot. The session persists across calls, keyed by the proxy config:
every call that carries the same proxy reconnects to the same warmed browser with the
current page, cookies, and /s results still loaded. So the normal pattern is warm up once,
then fire follow-up evaluate/goto calls against the live page — an extra step or a failed
step never means re-running the warm-up, and a follow-up evaluate-only call does not
hit a cold session. Batching the steps into one commands array (shown below) is only a
convenience that saves round trips; splitting them across calls is equally valid. The one hard
rule: pass the same proxy on every call — drop it and you land in a different, blank
session (see step 6). All filter/sort/pagination state lives in the /s URL query string, so
you build the URL once.
Set
proxy: { proxy: "residential", proxyCountry: "us" }on the call. Amazon needs the residential proxy — a bare/datacenter session gets an immediate Robot Check. Always pinproxyCountryto the storefront you're scraping (usfor amazon.com): without it the residential pool can land on an EU/other IP and amazon.com renders EUR (or another currency) prices, silently mismatching the hardcoded currency assumption. For a non-US storefront, pin the matching country (gbfor amazon.co.uk,defor amazon.de, …).Build the search URL. Base
https://www.amazon.com/s?k=<url+encoded+query>(encode spaces as+). Then append:&s=<sort>—relevanceblender(Featured, default),price-asc-rank,price-desc-rank,review-rank(Avg. Customer Review),date-desc-rank(Newest),exact-aware-popularity-rank(Best Sellers).&page=<N>— pagination (default page returns ~16–48 cards).&rh=<comma-joined key:value filter fragments>— see the encoding table in Gotchas.- For an ASIN list, skip search and open
https://www.amazon.com/dp/<ASIN>per ASIN. - For a full URL input, use it as-is (optionally append more
rhfragments).
Warm up, open, and wait for result cards. Shown below as one
commandsarray for convenience, but you can equally split these across separate calls that each repeat the sameproxy: { proxy: "residential", proxyCountry: "us" }— the warmed page carries over between calls:"commands": [ { "method": "goto", "params": { "url": "https://www.amazon.com/", "waitUntil": "load", "timeout": 45000 } }, { "method": "waitForTimeout", "params": { "time": 1500 } }, { "method": "goto", "params": { "url": "<search url>", "waitUntil": "load", "timeout": 45000 } }, { "method": "waitForSelector", "params": { "selector": "div[data-component-type=s-search-result]", "timeout": 10000 } }, { "method": "evaluate", "params": { "content": "<EXTRACTOR_JS>" } } ]Hitting
/first establishes cookies and avoids the fresh-session 503. IfwaitForSelectortimes out,textonbodyand check for a Robot Check (see Gotchas). On a transient 503, add areload+ re-wait.Extract with
evaluate(NOTsnapshot). The extractor returns a JSON string under.value—JSON.parseit →{ totalResultCount, resultCount, results[] }.Paginate / limit. For more than one page, add more
goto &page=2/&page=3+evaluatesteps to the same call and concatenateresults[].totalResultCounttells the caller the slice is partial.The session persists across calls — it does NOT tear down on return. It is keyed by the call's session config (
proxy/profile): a later call carrying the sameproxyreconnects to the same warmed, logged-in-to-cookies browser with the/spage still loaded; a call that dropsproxylands in a different (default, un-warmed) session that looks blank and will 503/Robot-Check on Amazon. So:- Pass the same
proxyon every call of the flow, not just the first. Dropping it is the usual cause of a "logged-out"/blank follow-up — re-issue withproxybefore assuming the session died. - Recover from a failed step by re-issuing that step alone (same
proxy), against the page still in the session — do not restart the warm-up batch. E.g. ifevaluatethrows, just callevaluateagain; the/spage is still there.
- Pass the same
The extractor (evaluate expression)
Pass this as a single-line string in
evaluate.content. Minify it (strip the newlines) before putting it in the JSON tool call — a pretty-printed multi-line blob stuffed into a JSON string field is the #1 cause ofSyntaxError: Invalid or unexpected tokenon the first try. Note there is no'\n'string literal anywhere in this extractor (the old version split the header on'\n', which mangles under JSON escaping); the header is a single-line read via.textContent, andString.fromCharCode(10)is used if you ever need a newline.
(() => {
const num = (s) => {
if (!s) return null;
const m = String(s).replace(/[^0-9.]/g, "");
return m ? parseFloat(m) : null;
};
const intnum = (s) => {
if (!s) return null;
const m = String(s).replace(/[^0-9]/g, "");
return m ? parseInt(m, 10) : null;
};
// Read currency from the price string, not the proxy assumption. Handles "$", "EUR",
// "£", "USD", etc. Returns a best-effort code/symbol or null.
const cur = (s) => {
if (!s) return null;
const code = String(s).match(/[A-Z]{3}/);
if (code) return code[0];
const sym = { $: "USD", "£": "GBP", "€": "EUR", "¥": "JPY" };
for (const k in sym) if (s.includes(k)) return sym[k];
return null;
};
const headerEl =
document.querySelector('[data-component-type="s-result-info-bar"]') ||
document.querySelector(".s-breadcrumb");
const headerTxt = headerEl ? headerEl.textContent.trim() : "";
const tm =
headerTxt.match(/of\s+(over\s+)?([\d,]+)\s+results/i) ||
headerTxt.match(/([\d,]+)\s+results/i);
const total = tm ? parseInt(tm[tm.length - 1].replace(/,/g, ""), 10) : null;
const cards = [
...document.querySelectorAll('div[data-component-type="s-search-result"]'),
];
const seen = new Set();
const results = [];
for (const c of cards) {
const asin = c.getAttribute("data-asin") || null;
if (!asin || seen.has(asin)) continue; // dedup: Amazon repeats cards across carousels
const h2 = c.querySelector("h2");
const img = c.querySelector("img.s-image");
// Full product title lives in the image alt text; the <h2> often holds only the
// brand line now. Prefer alt, strip the "Sponsored Ad - " prefix, fall back to h2.
let title = img ? (img.getAttribute("alt") || "").trim() : "";
title = title.replace(/^Sponsored Ad\s*-\s*/i, "").trim();
if (title.length < 5) title = h2 ? h2.innerText.trim() : null;
const priceOff = c.querySelector(".a-price:not(.a-text-price) .a-offscreen");
const listOff =
c.querySelector(".a-price.a-text-price .a-offscreen") ||
c.querySelector('[data-a-strike="true"] .a-offscreen');
const ratingEl = c.querySelector(".a-icon-alt");
let reviewCount = null;
for (const e of c.querySelectorAll("[aria-label]")) {
const a = e.getAttribute("aria-label");
if (/^[\d,]+\s+ratings?$/i.test(a)) {
reviewCount = intnum(a);
break;
}
}
const txt = c.innerText;
const curVal = num(priceOff?.textContent),
list = num(listOff?.textContent);
seen.add(asin);
results.push({
asin,
title: title || null,
imageUrl: img ? img.getAttribute("src") : null,
thumbnails:
img && img.getAttribute("srcset")
? [
...new Set(
img
.getAttribute("srcset")
.split(",")
.map((s) => s.trim().split(" ")[0]),
),
]
: [],
price:
curVal != null
? {
formatted: priceOff.textContent,
raw: curVal,
currency: cur(priceOff.textContent),
}
: null,
listPrice: list != null ? { formatted: listOff.textContent, raw: list } : null,
discountPercent:
curVal != null && list != null && list > curVal
? Math.round((1 - curVal / list) * 100)
: null,
rating: {
stars: ratingEl ? num(ratingEl.textContent.split(" ")[0]) : null,
reviewCount,
},
primeEligible: !!c.querySelector(
'[data-cy="delivery-recipe"] .prime-brand-color, i.a-icon-prime',
),
sponsored: !!c.querySelector(
'.puis-sponsored-label-text, .s-sponsored-label-text, [aria-label="View Sponsored information"]',
),
badges: [
/Amazon's Choice/i.test(txt) && "Amazon's Choice",
/Best Seller/i.test(txt) && "Best Seller",
/Climate Pledge Friendly/i.test(txt) && "Climate Pledge Friendly",
].filter(Boolean),
url: "https://www.amazon.com/dp/" + asin,
});
}
return JSON.stringify({
totalResultCount: total,
resultCount: results.length,
results,
});
})();Site-Specific Gotchas
A residential proxy is mandatory. With
proxy: { proxy: "residential" }the call loaded full results with no CAPTCHA across all test iterations. Do not run bare — expect an immediate Robot Check without it.Never
snapshoton/spages. Amazon's accessibility tree is enormous and can exceed the tool-result size limit, so thesnapshotcommand fails or truncates. Useevaluatefor all extraction. (text/htmlon the whole results container is also too large and returns inline script junk — scope any read to a single small element, or just use the extractor.)First load may 503 — warm up via the homepage. A transient "Sorry! Something went wrong!" page is common when the first navigation of a fresh session is the
/ssearch URL (no cookies yet). The fix (built into step 3):goto https://www.amazon.com/first, wait ~1.5 s, thengotothe search URL — all in the same call so cookies carry over. If a 503 still appears, add areload+ re-wait for the result-card selector.rh=filter node IDs are NOT stable constants — read them from the live filter rail. The "4 Stars & Up" link renderedp_72:1248879011on one query andp_72:1248915011on another, and both resolve to the same filter. The robust pattern: load a first, unfiltered results page, read thehrefof the desired filter's anchor in the left rail (#s-refinements a[href*="rh="]), copy itsrh=fragment, then re-open the URL with that fragment appended. Only the key names and thes=/k=/page=tokens are stable.Verified
rhkey names / encodings:Filter rhfragmentNotes Department / category n:<categoryNodeId>also addressable via &i=<alias>(e.g.i=electronics)Customer reviews (min stars) p_72:<id>id dynamic; read from rail (1/2/3/4-star surfaced) Price range p_36:<minCents>-<maxCents>cents, no decimals; open-ended 2500-or-5000; preset buckets are just specific rangesBrand p_89:<BrandName>orp_123:<id>key varies by category; multi-select pipe-joined ( |)Today's Deals p_n_deal_type:23566064011verified from rail Climate Pledge Friendly p_n_cpf_labels:<id>read from rail Free shipping / Prime delivery p_76:<id>/p_90:<id>category-dependent Seller p_6:<merchantId>; Amazon-as-seller&emi=ATVPDKIKX0DERmerchant IDs dynamic Category-specific facets (color, size, connectivity, fit, …) p_n_g-<id>:<value>always read from the rendered rail Combine multiple filters by comma-joining inside one
rh=:rh=n:172282,p_72:1248879011,p_36:2500-5000.primeEligibleis best-effort on logged-out search. Amazon rarely renders a definitive per-item Prime badge to a signed-out visitor; aprime-signup-ingressupsell appears on most cards and is NOT a reliable signal, so the extractor keys off the Prime brand-color logo inside the delivery recipe and may under-report. To guarantee Prime results, apply the Prime rail filter — then every returned item is Prime by construction.Title comes from the image
alt, NOT<h2>. On the current layout<h2>.innerTextfrequently returns only the brand line (e.g. bare"Skullcandy"). The full product name is inimg.s-image[alt]. The extractor readsalt, strips a leading"Sponsored Ad - ", and only falls back to<h2>whenaltis missing/too short. If you write your own extractor, do the same or every title collapses to the brand.Currency follows the proxy IP — don't hardcode it. amazon.com renders prices in the currency of the exit IP's region, so a residential proxy that lands in the EU yields
EURprices on the US storefront. Two independent fixes, apply both: pinproxyCountry: "us"(step 1) so you actually get USD, and read the currency from the price string (the extractor'scur()helper) rather than asserting"USD". A mismatch between the two is the tell that the proxy geo is wrong.Dedup by ASIN. Amazon repeats the same product across sponsored slots and carousels, so the raw card list contains duplicate
data-asinvalues. The extractor keeps aseenset; don't drop it or the caller gets the same item several times.brandis usually not surfaced as separate text on modern result cards (it lives in the title); leave itnullwhen absent rather than guessing.Result cards per page vary (~16 at a default desktop viewport, up to 48). Always read
totalResultCountfrom the header so the caller knows the slice is partial; paginate with&page=N.Non-US storefronts (
.co.uk,.de, …): therhkey names are the same but the numeric IDs, currency, and rail labels differ — always read filter encodings from that storefront's rendered rail, and read the currency from the price string.Robot Check handling. If
waitForSelectortimes out andtextonbodycontains "Enter the characters you see" / "Robot Check" / "we just need to make sure you're not a robot": thesolvecommand can attempt Amazon's captcha, but if it doesn't clear, do not keep hammering —screenshotit and return{ "success": false, "captchaEncountered": true, "error_reasoning": "<page text>" }. Triggers observed: bare/datacenter sessions and high request volume; a residential-proxy call avoided it entirely in testing.
Expected Output
Success (one page of a filtered query):
{
"success": true,
"query": "wireless mechanical keyboard",
"appliedFilters": {
"minRating": 4,
"priceRangeCents": null,
"sort": "price-asc-rank"
},
"totalResultCount": 5000,
"pageReturned": 1,
"resultCount": 16,
"results": [
{
"asin": "B0DXJQT19B",
"title": "Anker USB C Hub, 7in1 Multi-Port USB Adapter ...",
"brand": null,
"imageUrl": "https://m.media-amazon.com/images/I/71Z9T0VgGyL._AC_UY218_.jpg",
"thumbnails": [
"https://m.media-amazon.com/images/I/71Z9T0VgGyL._AC_UY218_.jpg",
"https://m.media-amazon.com/images/I/71Z9T0VgGyL._AC_UY327_FMwebp_QL65_.jpg"
],
"price": { "formatted": "$19.99", "raw": 19.99, "currency": "USD" },
"listPrice": { "formatted": "$25.99", "raw": 25.99 },
"discountPercent": 23,
"rating": { "stars": 4.6, "reviewCount": 3786 },
"primeEligible": false,
"sponsored": false,
"badges": ["Amazon's Choice"],
"url": "https://www.amazon.com/dp/B0DXJQT19B"
}
],
"captchaEncountered": false,
"error_reasoning": null
}Item with no sale / no reviews (nulls instead of omitted keys):
{
"asin": "B0CZ6S8PX5",
"title": "One Handed Gaming Keyboard 35 Keys ...",
"brand": null,
"imageUrl": "https://m.media-amazon.com/images/I/61D7NI7tdRL._AC_UY218_.jpg",
"thumbnails": [],
"price": { "formatted": "$7.99", "raw": 7.99, "currency": "USD" },
"listPrice": null,
"discountPercent": null,
"rating": { "stars": 5, "reviewCount": 1 },
"primeEligible": false,
"sponsored": false,
"badges": [],
"url": "https://www.amazon.com/dp/B0CZ6S8PX5"
}Blocked by Robot Check (do not solve — ship this shape):
{
"success": false,
"query": "wireless mechanical keyboard",
"totalResultCount": null,
"results": [],
"captchaEncountered": true,
"error_reasoning": "Robot Check — 'Enter the characters you see below. Sorry, we just need to make sure you're not a robot.'"
}