Shopping and listings

amazon.com

Amazon: 2 skills for AI agents

Notes an AI agent reads before it works on amazon.com: what to open, what to call, what breaks. Written by browser-harness contributors after real runs, and kept on GitHub under the MIT license.

Give it to your agent

Any coding agent

Start the task with this line. The Markdown version is the same notes without the page around them.

Read https://browser-use.com/skills/amazon.md, then <your task>

Browser Harness

Save the files into your workspace and turn domain skills on. The code below calls its helpers, such as http_get and js. What Browser Harness is.

d="${BH_AGENT_WORKSPACE:-$HOME/.config/browser-harness/agent-workspace}/domain-skills/amazon"
mkdir -p "$d" && cd "$d"
curl -sLO https://raw.githubusercontent.com/browser-use/browser-harness/main/agent-workspace/domain-skills/amazon/order-history.md
curl -sLO https://raw.githubusercontent.com/browser-use/browser-harness/main/agent-workspace/domain-skills/amazon/product-search.md
export BH_DOMAIN_SKILLS=1

Amazon - Order History Extraction

Field-tested against amazon.co.uk on a 2010-2026 account (1,007 orders), first on 2026-07-16 and re-verified end-to-end on 2026-07-29. Complements product-search.md, which covers the public catalogue; this file covers the logged-in /your-orders pages.

Auth

Order history requires login. An unauthenticated visit to any orders URL redirects to /ap/signin (title "Amazon Sign-In") - detect this and stop for a human login rather than typing credentials:

if "/ap/signin" in page_info()["url"]:
    raise RuntimeError("Amazon session not logged in — do a one-time headed login first")

A one-time headed login persists in the profile; no re-challenge was observed on later headless runs from the same profile/IP.

Navigation

First visit uses new_tab(), the same gotcha documented in product-search.md; goto_url() is fine for every page after that.

new_tab("https://www.amazon.co.uk/your-orders/orders?timeFilter=year-2025&startIndex=0")
wait_for_load()
wait(2)  # the order list hydrates after readyState=complete
  • timeFilter takes last30, months-3, then year-YYYY back to the account's first year. Read the valid values off the page rather than guessing:
    years = json.loads(js(
        "JSON.stringify(Array.from(document.querySelectorAll('#time-filter option')).map(o => o.value))"
    ))
  • startIndex steps in 10s (0, 10, 20, ...) - 10 orders per page.

Know the expected count before you paginate

The page states its own total, so a scrape can verify itself instead of guessing when to stop. This is the difference between a complete run and a short one that looks complete.

import re
count_text = js("document.querySelector('.num-orders')?.innerText")  # '186 orders' / '1 order'
count_match = re.fullmatch(r"\s*(\d{1,3}(?:,\d{3})+|\d+)\s+orders?\s*", count_text or "")
if not count_match:
    raise RuntimeError("Order count is missing or invalid; inspect the page before extracting orders")
expected = int(count_match.group(1).replace(",", ""))

If Amazon shows a separate empty-state message, verify that message before recording zero orders.

Stop on the pagination control, not on a short page - .a-last carries a-disabled on the final page and the whole .a-pagination block is absent when a year fits on one page:

is_last_page = bool(js("""
  (function () {
    const l = document.querySelector('.a-pagination .a-last');
    return !l || l.classList.contains('a-disabled');
  })()
"""))

Then assert what you collected against what the page promised, and fail loudly on a mismatch rather than writing a truncated file:

if len(collected) != expected:
    raise RuntimeError(f"year-{year}: got {len(collected)} of {expected} orders — investigate before trusting output")

Verified on year-2026: 57 expected, 6 pages, 57 collected, every order dated.

Extraction

Each order is an .order-card. Titles are the product links inside it; the order date is loose text in the card. js() hands back whatever the expression evaluates to, so a JSON.stringify payload needs json.loads on the Python side to become a list:

import json
 
page_orders = json.loads(js("""JSON.stringify(Array.from(document.querySelectorAll('.order-card')).map(c => {
  const m = c.innerText.match(/(\\d{1,2}\\s+[A-Za-z]+\\s+\\d{4}|[A-Za-z]+\\s+\\d{1,2},\\s+\\d{4})/);
  const titles = Array.from(new Set(Array.from(
    c.querySelectorAll('.yohtmlc-product-title, a.a-link-normal[href*="/dp/"], a.a-link-normal[href*="/gp/product/"]')
  ).map(a => a.innerText.trim()).filter(t => t && t.length > 2)));
  return {date: m ? m[1] : '', titles};
}))"""))

The date alternation covers both 31 December 2024 (co.uk) and December 31, 2024 (.com); a UK-only pattern silently yields date: '' for every order on the US site.

Gotchas

  • Wait ~2s after wait_for_load() before querying cards. readyState=complete fires before the list renders, exactly as on search results.
  • Trust .num-orders, not an empty page. A year returning zero cards is a signal to inspect the DOM, never evidence of "no orders" - an earlier run on this account read year-2024+ as empty and was wrong. Re-verified 2026-07-29: year-2010 through year-2026 all extract with .order-card, 10 per page.
  • Don't stop on a short page alone. The final page is legitimately short (7 of 10 on year-2026), so a short-page-means-done rule is indistinguishable from a page that under-rendered. Use .a-last plus the count assertion.
  • Digital items (Kindle, Prime Video) sit alongside physical ones with no reliable in-card type marker - classify by title downstream.
  • Long runs: ~2-3s per page, and grocery-heavy years run 20+ pages. Checkpoint to disk after each year and print(..., flush=True) - a killed run with buffered stdout loses everything.

order-history.md · updated Oct 9, 2026 · 654 words

Amazon - Product Search & Data Extraction

Field-tested against amazon.com on 2025-04-18 using a logged-in Chrome session. No CAPTCHA or bot detection was triggered during any test run.

Navigation

Direct search URL (fastest, always use this)

goto_url("https://www.amazon.com/s?k=mechanical+keyboard")
wait_for_load()
wait(2)  # dynamic content needs ~2s after readyState=complete

Search box typing (use when you need category filtering)

goto_url("https://www.amazon.com")
wait_for_load()
wait(1)
js("document.querySelector('#twotabsearchtextbox').focus()")
js("document.querySelector('#twotabsearchtextbox').click()")
wait(0.3)
type_text("wireless mouse")
wait(0.3)
press_key("Enter")
wait_for_load()
wait(2)

Direct product page

# URL pattern: /dp/{ASIN}  or  /dp/{ASIN}?th=1 (Amazon may redirect to add ?th=1)
goto_url("https://www.amazon.com/dp/B08Z6X4NK3")
wait_for_load()
wait(2)

Session Gotcha

Always use new_tab() when opening Amazon for the first time in a harness session. goto_url() can silently fail to navigate if the current tab resists the navigation (observed when the daemon attached to a different real tab). The safe pattern:

tid = new_tab("https://www.amazon.com/s?k=mechanical+keyboard")
wait_for_load()
wait(2)

After that, goto_url() works fine within the same Amazon session.

Search Results Extraction

Container selector

[data-component-type="s-search-result"] - confirmed working, yields ~22 results per page.

Full extraction (field-tested)

results = js("""
  Array.from(document.querySelectorAll('[data-component-type="s-search-result"]')).map(el => ({
    asin: el.getAttribute('data-asin'),
    title: el.querySelector('h2 span')?.innerText?.trim(),
    price: el.querySelector('.a-price .a-offscreen')?.innerText,
    list_price: el.querySelector('.a-text-price .a-offscreen')?.innerText,
    rating: el.querySelector('[aria-label*="out of 5 stars"]')?.getAttribute('aria-label')?.split(' ')[0],
    reviews: el.querySelector('[aria-label*="ratings"]')?.getAttribute('aria-label'),
    is_sponsored: !!el.querySelector('.puis-sponsored-label-text'),
    url: el.querySelector('h2 a')?.href
  }))
""")

Field notes

  • asin: data-asin attribute on the container div - always present, matches the /dp/{ASIN} URL.
  • title: h2 span works consistently. h2 a.a-link-normal span also works.
  • price: .a-price .a-offscreen returns the formatted string e.g. "$69.99". Use this, not .a-price-whole.
  • list_price: .a-text-price .a-offscreen - only present when item is on sale (was/now pricing).
  • rating: Use aria-label on [aria-label*="out of 5 stars"] - gives "4.5 out of 5 stars, rating details", split on space for the number.
  • reviews: Use [aria-label*="ratings"] attribute - gives "1,514 ratings". Do NOT use .a-size-base.s-underline-text - that element exists on sponsored results and shows "Xbox" (a cross-sell widget text).
  • is_sponsored: .puis-sponsored-label-text is present on sponsored listings; first 2-3 results are usually sponsored.
  • url: h2 a href - contains the full /dp/{ASIN}/... URL.

Product Detail Page Extraction

Confirmed selectors (field-tested on B08Z6X4NK3)

detail = js("""
  ({
    title: document.querySelector('#productTitle')?.innerText?.trim(),
    price: (function() {
      var whole = document.querySelector('.a-price-whole')?.innerText?.replace(/[\\n.]/g,'');
      var frac  = document.querySelector('.a-price-fraction')?.innerText;
      return (whole && frac) ? '$' + whole + '.' + frac
           : document.querySelector('.a-price .a-offscreen')?.innerText || null;
    })(),
    list_price: document.querySelector('.basisPrice .a-offscreen')?.innerText,
    rating: document.querySelector('#acrPopover')?.getAttribute('title'),
    review_count: document.querySelector('#acrCustomerReviewText')?.innerText,
    availability: document.querySelector('#availability span')?.innerText?.trim(),
    brand: document.querySelector('#bylineInfo')?.innerText?.trim(),
    asin: document.querySelector('input[name="ASIN"]')?.value,
    bullet_points: Array.from(document.querySelectorAll('#feature-bullets li span.a-list-item'))
                       .map(e => e.innerText?.trim()).filter(t => t)
  })
""")

Price field notes

  • #priceblock_ourprice and #priceblock_dealprice are legacy - they return null on modern product pages.
  • Construct price from .a-price-whole + .a-price-fraction (both stripped of \n and .).
  • As a fallback: first .a-price .a-offscreen on the page also works (confirmed $69.99).
  • list_price from .basisPrice .a-offscreen shows the crossed-out "was" price when a discount exists.

Best Sellers Page

URL: https://www.amazon.com/Best-Sellers-{Category}/zgbs/{slug}/ e.g. https://www.amazon.com/Best-Sellers-Electronics/zgbs/electronics/

DOM structure (2025)

.zg-item-immersion does not exist - Amazon migrated to CSS modules. Use [data-asin] anchored on [id="gridItemRoot"]:

goto_url("https://www.amazon.com/Best-Sellers-Electronics/zgbs/electronics/")
wait_for_load()
wait(2)
 
items = js("""
  Array.from(document.querySelectorAll('[data-asin]')).map(el => {
    var container = el.closest('[id="gridItemRoot"]') || el;
    return {
      asin: el.getAttribute('data-asin'),
      rank: container.querySelector('[class*="zg-bdg-text"]')?.innerText,
      title: container.querySelector('img[alt]')?.getAttribute('alt'),
      price: container.querySelector('.p13n-sc-price, .a-size-base.a-color-price')?.innerText,
      url: 'https://www.amazon.com/dp/' + el.getAttribute('data-asin')
    }
  }).filter(r => r.rank)
""")

Note: Title comes from the product image alt attribute - the text title elements use obfuscated CSS module class names that change between deployments.

Pagination

# Get next page URL directly
next_url = js("document.querySelector('.s-pagination-next')?.href")
if next_url:
    goto_url(next_url)
    wait_for_load()
    wait(2)
 
# Or construct by page number
goto_url("https://www.amazon.com/s?k=wireless+mouse&page=2")

Result Count

count_text = js("document.querySelector('[data-component-type=\"s-result-info-bar\"] h1')?.innerText?.trim()")
# Returns e.g.: '1-16 of over 40,000 results for "wireless mouse"\nSort by:\n...'
# Extract just the count: count_text.split('\n')[0]

CAPTCHA Detection

No CAPTCHA was encountered during testing with a logged-in Chrome session. To detect defensively:

def check_captcha():
    text = js("document.body.innerText.slice(0,500)") or ""
    url  = page_info()["url"]
    return (
        "captcha" in text.lower()
        or "enter the characters" in text.lower()
        or "sorry, we just need to make sure" in text.lower()
        or "captcha" in url.lower()
        or "validateCaptcha" in url
    )
 
if check_captcha():
    raise RuntimeError("Amazon CAPTCHA hit — stop and notify user")

Amazon may serve a CAPTCHA on fresh/anonymous sessions. Using the browser's existing logged-in session avoids this in practice.

Gotchas

  • goto_url() silent failure: On first visit, use new_tab(url) instead. After the tab is on Amazon, goto_url() works.
  • .zg-item-immersion is gone: Best Sellers page uses CSS module classes (obfuscated). Use [data-asin] + img[alt] for title.
  • .a-size-base.s-underline-text is unreliable for review count: On sponsored results it shows unrelated text (e.g. "Xbox"). Use [aria-label*="ratings"] instead.
  • #priceblock_ourprice is legacy: Returns null on modern pages. Construct from .a-price-whole + .a-price-fraction.
  • Sponsored results appear first: First 2-3 results are almost always is_sponsored: true. Filter them out with !el.querySelector('.puis-sponsored-label-text') when you need organic results.
  • data-asin can be empty string on non-product rows: Filter with .filter(r => r.asin).
  • Price split DOM: .a-price-whole innerText includes a trailing \n. - strip it: .replace(/[\n.]/g,'').
  • ASIN from URL: Use /dp/([A-Z0-9]{10})/ regex on the product URL. data-asin on search results is always the canonical ASIN.
  • ?th=1 redirect: Amazon appends ?th=1 (and sometimes ?psc=1) to product URLs after redirect. This is normal - input[name="ASIN"] always has the clean ASIN.
  • Wait 2s after wait_for_load(): Amazon search results load the listing cards asynchronously. readyState=complete fires before cards render. A hard 2s wait is required.

product-search.md · updated Apr 28, 2026 · 893 words

Sites change. These notes describe what worked when they were last updated, not a guarantee they still do. If something broke, the fix is a pull request to browser-harness.