How to scrape Amazon product data in Python, and what a proxy changes
We expected blocks and got almost none. What a residential exit changes is which price, stock and delivery location the page shows.
In short
Request amazon.com/dp/ASIN with browser-like headers through an exit in the marketplace’s country, and treat a response as good only when it contains the product title. Amazon sets the delivery location from the IP, so the exit decides which price and availability you collect.
Most guides open with “Amazon blocks every datacenter IP”. We tested that before repeating it. From a server in a German data center we requested four product pages in a loop, 80 requests without a pause. Then we sent requests through US residential exits, a new exit for each one.
| From | Requests | Product page | Other |
|---|---|---|---|
| Datacenter IP, browser headers | 80 | 80 | 0 |
| Datacenter IP, python-requests User-Agent | 4 | 4 | 0 |
| US residential exits, browser headers | 28 | 27 | 1 × 200 with a 3.7 KB page |
| US residential exits, python-requests defaults | 4 | 4 | 0 |
No CAPTCHA in any of them. At this volume, Amazon served product pages to a data center, and to a script that announced itself as python-requests. That will not hold at any volume: we did not test thousands of requests an hour, and a single IP that does that is easy to rate-limit. But it does mean a proxy isn’t what makes a small scraper work.
Two things did go wrong, and neither was a block page with a clear status. One response came back 200 with 3.7 KB of HTML and no product in it. And from exits outside the US the page loaded fine but had no price. The next section is about that one.
Amazon chooses a delivery location from the IP address of the request, down to the ZIP code, and shows price and availability for that location. We requested the same product through four exits:
| Exit | Delivery location on the page | Price | Availability |
|---|---|---|---|
| -country-us | Delivering to Highland … 44143 | $79.99 | In Stock |
| -country-us-city-chicago | Delivering to Chicago 60619 | $79.99 | In Stock |
| -country-de | Deliver to Germany | none | Cannot be shipped to your location |
| -country-gb | Deliver to United Kingdom | none | Cannot be shipped to your location |
-country-us-city-chicago.So a scraper on a server in Frankfurt collects “no price, not available” for a product any US shopper can buy. That is the real job of the proxy here: put the request where the shopper is. For amazon.com use US exits, for amazon.de German ones. Add a city when you compare delivery or stock between regions.
The scraper
One file. It needs requests, beautifulsoup4 and lxml, and reads your proxy credentials from the environment.
import os
import re
import time
import uuid
import requests
from bs4 import BeautifulSoup
USER = os.environ["PROXY_USER"]
PASS = os.environ["PROXY_PASS"]
HEADERS = {
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
"(KHTML, like Gecko) Chrome/141.0.0.0 Safari/537.36",
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
"Accept-Language": "en-US,en;q=0.9",
}
def proxy(country: str = "us", city: str = "") -> dict:
# A new session label per call = a new exit IP per request (sticky port).
user = f"{USER}-country-{country.lower()}"
if city:
user += f"-city-{city.lower()}"
url = f"http://{user}-session-{uuid.uuid4().hex[:8]}-lifetime-3:{PASS}@via.proxies.vision:10000"
return {"http": url, "https": url}
def fetch(asin: str, country: str = "us", city: str = "", attempts: int = 3) -> str | None:
for attempt in range(attempts):
try:
r = requests.get(f"https://www.amazon.com/dp/{asin}", headers=HEADERS,
proxies=proxy(country, city), timeout=40)
except requests.RequestException:
time.sleep(attempt + 1) # a dropped exit: try the next one
continue
if r.status_code == 404:
return None # the product doesn't exist
if r.status_code == 200 and 'id="productTitle"' in r.text:
return r.text
time.sleep(2 * (attempt + 1)) # a 200 without a product is not a success
return None
def parse(html: str) -> dict:
soup = BeautifulSoup(html, "lxml")
def text(selector: str) -> str:
el = soup.select_one(selector)
return el.get_text(" ", strip=True) if el else ""
rating = re.search(r"([\d.]+) out of", text("#acrPopover .a-icon-alt"))
return {
"title": text("#productTitle"),
"price": text("#corePrice_feature_div .a-offscreen"),
"rating": rating.group(1) if rating else "",
"reviews": re.sub(r"\D", "", text("#acrCustomerReviewText")),
"availability": text("#availability"),
"location": text("#glow-ingress-block").replace("Update location", "").strip(),
}
if __name__ == "__main__":
for country, city in [("us", ""), ("us", "chicago"), ("de", "")]:
html = fetch("B09B8V1LZ3", country, city)
print(country, city, parse(html) if html else "no product page")
time.sleep(2)A new exit per request
proxy() adds a random session label to the username and uses the sticky port, so every request leaves from a different IP, whatever HTTP client you use. Without it a client that keeps connections open reuses one exit; we measured that here.
Success means a product title
fetch() accepts a response only if it contains id="productTitle". That one check catches every kind of non-product page, whatever its status code, and the retry goes out through a new exit.
Selectors change
The selectors in parse() worked on October 14, 2026. Amazon changes its markup and tests layouts, so treat an empty field as a signal to look at the HTML, and keep the raw pages for a while so you can re-parse them.
Run with a US exit, a Chicago exit and a German one, it printed (titles shortened):
us {'title': 'Amazon Echo Dot (newest model) - …', 'price': '$79.99', 'rating': '4.7', 'reviews': '200847', 'availability': 'In Stock', 'location': 'Delivering to Highland ... 44143'}
us chicago {'title': 'Amazon Echo Dot (newest model) - …', 'price': '$79.99', 'rating': '4.7', 'reviews': '200847', 'availability': 'In Stock', 'location': 'Delivering to Chicago 60619'}
de {'title': 'Amazon Echo Dot (newest model) - …', 'price': '', 'rating': '4.7', 'reviews': '200847', 'availability': 'This item cannot be shipped to your selected delivery location. …', 'location': 'Deliver to Germany'}1.Go slowly
Our numbers come from small runs. Start with a few requests a second across all exits, watch the share of responses without a product title, and slow down when it rises.
2.One marketplace, one country
Match the exit to the marketplace:
-country-usfor amazon.com,-country-defor amazon.de,-country-gbfor amazon.co.uk. Otherwise you collect the “cannot be shipped” version of every page.3.Count the traffic
A product page was 250 to 360 KB compressed in our runs, about 3,000 pages per GB. Keep gzip on (requests does by default) and don’t download images.
4.Route only what needs it
If all you need is a title or a rating, which don’t depend on location, a plain server may do. Send the location-dependent requests through residential exits and save the per-GB traffic for those.
Amazon’s conditions of use restrict automated access, and the rules for collecting public data differ by country. Stick to public product data and check what applies to you before running this at volume.
Do I need proxies to scrape Amazon?
Why is the price missing or different in my scraper?
How much proxy traffic does an Amazon product page use?
How do I know a request was blocked?
Is scraping Amazon allowed?
Keep reading
Tunnel connection failed: 407 in Python requests (and 403, 465)
The status is the proxy's answer, not the website's. Five causes of 407 we reproduced, and what 403 and 465 mean instead.
7 min read · #python#errors
Selenium Proxy With Username and Password (Chrome): What Works in 2026
Chrome under Selenium ignores a proxy login in --proxy-server and has no SOCKS5 auth. What each attempt does, and a local forwarder that works.
5 min read · #python#authentication
aiohttp Proxy Errors: ClientHttpProxyError 407, SOCKS5 and trust_env
aiohttp's ClientHttpProxyError explained, why its message leaks your proxy password, socks5:// without aiohttp-socks, and rotation.
6 min read · #python#errors
Residential proxies, pay per GB
HTTP and SOCKS5. Country, city and sticky sessions in the username. No subscription.