Three Amazon scraping jobs in Python, each driven by a URL: product data, search results, and the star distribution behind a listing. Every request below was executed on 27 July 2026 against Chocodata and the numbers are that run.
HTTP 200 in 3.17s
fields: 60
title: Amazon Kindle Paperwhite 16GB (newest model)
price: 159.99 USD
TL;DR
amazon/producttakes a product URL and returns 60 top-level fields in about 3 seconds, with nodataenvelope to unwrap.amazon/searchtakes a search URL and returned 22 rows, 16 organic and 6 sponsored, formechanical keyboard.rating_stars_distributionreturns 5 bands. Recomputing the mean for the Fire TV Stick gave 4.66 against a listed 4.6.- Sponsored rows carry
organic_position: Noneand some rows carryprice: None, sosorted()raisesTypeErrorunless you filter first.
Why is it hard to scrape Amazon?
Amazon layers its defences: datacenter IPs are scored before your headers are parsed, product markup is A/B tested so the same URL returns different DOM shapes minutes apart, and sequential access patterns get profiled. The expensive one is the robot check, which arrives as HTTP 200, so any scraper branching on r.status_code == 200 logs a clean run while writing empty rows. That single behaviour causes more wasted debugging than the other three combined.
Prerequisites
- A free Chocodata API key, taken from the dashboard after sign-up. Free tier, no card.
-
Python 3.9+ and
requests. Tested here on Python 3.13.7 with requests 2.34.2, July 2026. Other languages hit the same endpoints, but every snippet below is Python.
python -m venv .venv && . .venv/bin/activate
pip install requests
- The Amazon URL you want to scrape. Product page or search page, copied from the address bar.
Fetch product data from a product URL
To scrape Amazon product data, send the product URL as url and read the fields straight off the response. There is no wrapper object.
1. Send the URL and assert on a field, not the status
import time
import requests
BASE = "https://api.chocodata.com/api/v1"
API_KEY = "YOUR_API_KEY"
URL = "https://www.amazon.com/dp/B0CFPJYX7P"
r = requests.get(f"{BASE}/amazon/product",
params={"url": URL, "api_key": API_KEY}, timeout=90)
r.raise_for_status()
product = r.json()
assert product.get("asin"), "empty payload behind a 200"
print("fields:", len(product))
print("title:", product["title"][:44])
print("price:", product["price"], product["currency"])
fields: 60
title: Amazon Kindle Paperwhite 16GB (newest model)
price: 159.99 USD
2. Map the fields you actually store
KEEP = ("asin", "parent_asin", "brand", "price", "price_buybox", "currency",
"rating", "reviews_count", "max_quantity", "has_videos")
for key in KEEP:
print(f"{key:>16}: {product[key]}")
asin: B0CFPJYX7P
parent_asin: B0DFYWDGW6
brand: Amazon
price: 159.99
price_buybox: 159.99
currency: USD
rating: 4.7
reviews_count: 21493
max_quantity: 5
has_videos: True
3. Flatten the variation list to CSV
import csv
with open("variations.csv", "w", newline="", encoding="utf-8") as fh:
w = csv.writer(fh)
w.writerow(["asin", "color", "style", "configuration"])
for v in product["variations"]:
d = v["dimensions"]
w.writerow([v["asin"], d.get("Color"), d.get("Style"), d.get("Configuration")])
print("rows:", len(product["variations"]))
asin,color,style,configuration
B0CTMS23DZ,Jade,With 3 months of Kindle Unlimited,Without Lockscreen Ads
B0CTMR865D,Raspberry,With 3 months of Kindle Unlimited,Ad-supported
B0CFPTK5JG,Raspberry,Without Kindle Unlimited,Ad-supported
B0CFPJYX7P,Black,Without Kindle Unlimited,Ad-supported
B0DDZJS3SB,Black,Without Kindle Unlimited,Without Lockscreen Ads
rows: 12
dimensions keys vary per product line, which is why .get() beats [] here. The search endpoint returns a flatter shape and is covered next.
Fetch search results from a search URL
To scrape Amazon search results, send the search page URL unchanged. The keyword stays inside the URL, so nothing needs parsing out of it.
1. Request the search URL unchanged
SEARCH = "https://www.amazon.com/s?k=mechanical+keyboard"
r = requests.get(f"{BASE}/amazon/search",
params={"url": SEARCH, "api_key": API_KEY}, timeout=90)
r.raise_for_status()
search = r.json()
print("products:", len(search["products"]))
print("keys:", sorted(search["products"][0])[:6])
products: 22
keys: ['asin', 'badges', 'best_seller', 'brand', 'climate_pledge_friendly', 'currency']
2. Split sponsored rows from organic rows
organic = [p for p in search["products"] if not p["is_sponsored"]]
print(len(organic), "organic of", len(search["products"]))
for p in organic[:5]:
print(f'{p["organic_position"]:>2} {p["asin"]} {str(p["price"]):>7} {p["rating"]} {p["title"][:42]}')
16 organic of 22
1 B0CF3VGQFL 29.99 4.3 Redragon Mechanical Gaming Keyboard Wired,
2 B0CDWP1D58 36.99 4.5 Redragon K668 108-Key Hot-Swap Wired RGB G
3 B09LK1P1RD 156.9 4.2 Logitech MX Mechanical Wireless Illuminate
4 B09JG7KRC7 43.31 4.4 Keychron C2 Full Size Wired Mechanical Key
5 B0C8QYB8W6 89.99 4.5 Razer BlackWidow V4 X Mechanical Gaming Ke
3. Page the URL and dedupe by ASIN
def fetch_page(keyword, page):
url = f"https://www.amazon.com/s?k={keyword}&page={page}"
r = requests.get(f"{BASE}/amazon/search",
params={"url": url, "api_key": API_KEY}, timeout=90)
r.raise_for_status()
return r.json()["products"]
rows = {}
for page in (1, 2):
got = fetch_page("mechanical+keyboard", page)
print(f"page {page}: {len(got)} rows")
for p in got:
rows[p["asin"]] = p
print("unique asins:", len(rows))
page 1: 22 rows
page 2: 22 rows
unique asins: 37
Pages overlapped by 7 ASINs on that run, so keying the dict on asin matters more than it looks. Rating data needs no extra call, which the next section uses.
Fetch ratings and the star distribution
To scrape Amazon reviews data, read the rating fields off the same product response. One call per listing covers the score, the volume and the five bands.
1. Read rating and reviews_count
FIRE = "https://www.amazon.com/dp/B0BP9MDCQZ"
r = requests.get(f"{BASE}/amazon/product",
params={"url": FIRE, "api_key": API_KEY}, timeout=90)
r.raise_for_status()
item = r.json()
print("rating:", item["rating"])
print("reviews_count:", item["reviews_count"])
rating: 4.6
reviews_count: 111766
2. Expand rating_stars_distribution
for b in item["rating_stars_distribution"]:
bar = "#" * (b["percentage"] // 2)
print(f'{b["rating"]} star {b["percentage"]:>3}% {bar}')
5 star 83% #########################################
4 star 9% ####
3 star 3% #
2 star 1%
1 star 4% ##
3. Convert percentages to counts and recheck the mean
total = item["reviews_count"]
counts = {b["rating"]: round(total * b["percentage"] / 100)
for b in item["rating_stars_distribution"]}
mean = sum(k * v for k, v in counts.items()) / sum(counts.values())
for k in sorted(counts, reverse=True):
print(f"{k} star: {counts[k]:>7,}")
print(f"weighted mean: {mean:.2f} (listed {item['rating']})")
print(f"negative share: {(counts[1] + counts[2]) / sum(counts.values()) * 100:.1f}%")
5 star: 92,766
4 star: 10,059
3 star: 3,353
2 star: 1,118
1 star: 4,471
weighted mean: 4.66 (listed 4.6)
negative share: 5.0%
The recomputed mean lands 0.06 above the listed rating because the band percentages are rounded to whole numbers, so treat it as a cross-check rather than a replacement. The reviews list on the same response carries the eight most recent reviewers with author, rating, timestamp, is_verified and helpful_count, so reviewer-level tracking works without the written text.
The part that breaks
Nulls in the search rows, not the requests. On the 22-row mechanical keyboard response, 6 rows had organic_position: None because they were sponsored, 1 row had price: None, 1 had rating: None and 4 had sales_volume: None. Sorting straight off those keys raises:
Traceback (most recent call last):
File "scrape.py", line 32, in <module>
cheapest = sorted(prods, key=lambda p: p["price"])
TypeError: '<' not supported between instances of 'NoneType' and 'float'
Traceback (most recent call last):
File "scrape.py", line 38, in <module>
ranked = sorted(prods, key=lambda p: p["organic_position"])
TypeError: '<' not supported between instances of 'NoneType' and 'NoneType'
Filter before you sort, and use a sentinel for the rank so sponsored rows sort last instead of blowing up:
prods = search["products"]
priced = [p for p in prods if p["price"] is not None]
cheapest = sorted(priced, key=lambda p: p["price"])
ranked = sorted(prods, key=lambda p: p["organic_position"] or 999)
Two more things worth knowing before you build on this. Search row counts move between runs, 16 on one wireless mouse call and 22 on the keyboard call, so never hard-code a slice length. And the entries in reviews come back with content and title unset, so any pipeline expecting review sentences needs to run on rating_stars_distribution instead.
Full script
"""Amazon product, search and rating scraper. Python 3.13.7, requests 2.34.2."""
import csv
import sys
import time
import requests
BASE = "https://api.chocodata.com/api/v1"
API_KEY = "YOUR_API_KEY"
def call(path, url, timeout=90, tries=3):
"""One GET, retried, so a batch run survives a dropped response."""
for attempt in range(tries):
r = requests.get(f"{BASE}/{path}",
params={"url": url, "api_key": API_KEY}, timeout=timeout)
if r.ok:
return r.json()
time.sleep(2)
r.raise_for_status()
def product(url):
p = call("amazon/product", url)
if not p.get("asin"):
raise RuntimeError(f"empty payload for {url}")
return p
def star_counts(p):
total = p["reviews_count"]
return {b["rating"]: round(total * b["percentage"] / 100)
for b in p.get("rating_stars_distribution") or []}
def search(keyword, pages=2):
rows = {}
for page in range(1, pages + 1):
url = f"https://www.amazon.com/s?k={keyword}&page={page}"
for row in call("amazon/search", url)["products"]:
rows[row["asin"]] = row
return list(rows.values())
def main():
p = product("https://www.amazon.com/dp/B0CFPJYX7P")
print(f'{p["title"][:44]} | {p["price"]} {p["currency"]} | {p["rating"]}')
for star, n in sorted(star_counts(p).items(), reverse=True):
print(f" {star} star: {n:>7,}")
rows = search("mechanical+keyboard", pages=2)
priced = [r for r in rows if r["price"] is not None]
priced.sort(key=lambda r: r["price"])
print(f"{len(rows)} unique asins, {len(priced)} priced")
with open("amazon.csv", "w", newline="", encoding="utf-8") as fh:
w = csv.writer(fh)
w.writerow(["asin", "title", "price", "rating", "reviews_count", "sponsored"])
for r in priced:
w.writerow([r["asin"], r["title"], r["price"], r["rating"],
r["reviews_count"], r["is_sponsored"]])
print("wrote amazon.csv")
if __name__ == "__main__":
sys.exit(main())
Summary
Three Amazon jobs run off one request shape: a product URL returns 60 top-level fields including the rating and the five star bands, and a search URL returns product rows with sponsored flags and organic positions. What the payload does not carry is review body text, so this covers price tracking, rank tracking and score monitoring rather than sentiment reading. The habit that saves the most time is asserting on asin rather than on the status code, and filtering None out of every key you intend to sort on.
FAQ
How many fields does the Amazon product response return?
Sixty top-level fields on both products tested, covering price, rating, images, variations and the star distribution.
Why does sorting search rows raise TypeError?
Sponsored rows carry organic_position: None and a few listings carry price: None, so filter the nulls out or supply a sentinel before sorting.
Does the star distribution match the listed rating?
Close but not exact: recomputing the mean from the Fire TV Stick bands gave 4.66 against a listed 4.6, because the band percentages are rounded to whole numbers.










Top comments (0)