Amazon Product Scraper for extracting product prices, titles, ratings, review counts, specifications and sales rank from Amazon.com product pages. This repo has a free Amazon product script you can run right now, and an Amazon product data API that returns real structured JSON from an ASIN.
Last updated: 2026-07-20. Working against Amazon.com as of July 2026, and re-verified whenever Amazon changes their markup.
The uncut responses behind every block on this page are committed in amazon_product_api_data/, captured 2026-07-20, and each block states what was trimmed from it. Every code example calls the API and is runnable from amazon_product_api_codes/.
This repo covers one endpoint in depth. For Amazon search results, category listings and the rest of the marketplace, see the Amazon Scraper.
pip install requests
export CHOCODATA_API_KEY="your_key" # free: 1,000 requests, one-time, no card
python amazon_product_api_codes/product.py 0143127748Those three lines return this, live from Amazon.com (10 of the 60 top-level fields, picked to fit; the full sample carries all 60):
{
"asin": "0143127748",
"title": "The Body Keeps the Score: Brain, Mind, and Body in the Healing of Trauma",
"price": 14.15,
"price_strikethrough": 19,
"currency": "USD",
"discount_percentage": 26,
"stock": "In Stock",
"rating": 4.8,
"reviews_count": 84491,
"answered_questions_count": 0
}...out of 39 populated fields, including a 12-key spec table, a 5-level category ladder and the star histogram:
That is the whole point of this repo. The rest of this page is the free script, what a plain client actually gets back from Amazon, and the endpoint with its parameters and response.
- Free Amazon Product Scraper
- Avoid getting blocked when scraping Amazon product pages
- Amazon Product Scraper API reference
- Track Amazon price changes for a list of ASINs
- Measured latency
Amazon renders the product page server side, so the title, price, rating and review count are in the HTML of /dp/{ASIN} when Amazon decides to send them. No headless browser and no JavaScript rendering required for the parse. No key, no cost:
pip install requests beautifulsoup4
python free_scraper/amazon_product_free_scraper.py 0143127748 --n 3Source: free_scraper/amazon_product_free_scraper.py. It fetches /dp/{ASIN}, reads #productTitle, the .a-price .a-offscreen value, #acrPopover and #acrCustomerReviewText, and emits asin, title, price, currency, rating, reviews_count, stock.
It sends a normal desktop browser User-Agent, because that is what gets the fullest response and a comparison against a deliberately weak client would prove nothing. Pass --bare to drop the header and watch what happens.
It partly works, and the interesting part is which half. The next section is that measurement.
Mostly, you do not get blocked. Every one of the 30 fetches behind this section came back 200 with a parseable product title, and none of them hit a captcha or a 503. What costs you data here is that the response is partial, and how partial depends on one request header and on the item.
We ran the script above against Amazon.com 30 times, across 3 ASINs, both with and without a browser User-Agent, changing nothing else. Every fetch is in free_scraper_survey.json:
Measured on /dp/, n=15 each, 3 ASINs, residential connection in Lithuania, 2026-07-20 |
No User-Agent | Chrome 124 User-Agent |
|---|---|---|
| HTTP status | 200 on 15 of 15 | 200 on 15 of 15 |
| Response size | 579,455 to 1,224,519 bytes | 1,403,563 to 2,813,247 bytes |
#productTitle parsed |
15 of 15 | 15 of 15 |
rating parsed |
0 of 15 | 15 of 15 |
reviews_count parsed |
0 of 15 | 15 of 15 |
price parsed |
0 of 15 | 5 of 15 |
Prices matching $1,234.56 anywhere in the body |
0 on 10 of 15, 1 on 5 | 49 on the ASIN that priced, 1 and 0 on the other two |
Two things fall out of that table, and neither is the one you would guess.
One header decides whether the page has any commerce data in it. Drop the User-Agent and you get a 1.2 MB HTTP 200 carrying the product title, the publisher blurb and the detail bullets, and not one dollar sign anywhere in the body. Add it and the same URL returns 2.8 MB with the rating on every ASIN we tried. Reproduce it in ten seconds: run the script with and without --bare and compare the price-shaped-string counts it prints. The generalisable lesson is not "always send a User-Agent": on other targets that same header is what empties the response. It is that a header is part of the request contract, and which fields you get back is worth measuring per target rather than assuming.
Even with the header, the price is the field you cannot count on. It parsed on 5 of 15, and those 5 are all the same ASIN. On the other two the page came back at full size with the rating and review count present and no buybox price at all: one carried a single $19.99 belonging to a subscription plan advertised in the page copy, the other carried no price-shaped string whatsoever. The rating is the easy field here. The price is the hard one, and it is the one people build on.
| What bites you | Why | What it costs you |
|---|---|---|
| The page arrives, the price does not | A parser that checks response.ok and then reads a price selector gets None, not an exception. Nothing in the status line, the byte count or the <title> marks the response as partial. |
The expensive failure is not a crash. It is a price history full of nulls that looks like "this item had no price that day". |
| The title parses, so naive checks pass | #productTitle came back on 30 of 30 fetches, including every one with no price in it. A scraper that validates "did I get the product page?" by looking for a title concludes yes every time. |
Validate on the field you actually came for, not on the one that is easiest to find. |
| Coverage is per item, not per site | Three ASINs, one header set, three different outcomes. A scraper proven against one product is not proven. | A pipeline that works on your test ASIN and returns nulls across a real catalogue. |
| The same body comes back byte for byte | The bare fetch of the sample ASIN returned exactly 1,224,519 bytes on 5 of 5. Identical byte counts across independent requests are worth noticing before you conclude anything from a sample. | Vary the ASIN and re-run, or you are measuring one response repeatedly and calling it fifteen. |
| Where you run it is part of the input | Every measurement here was taken from a residential connection in Lithuania. Cloud egress ranges are published and widely filtered, so a result from your laptop does not carry over to CI unchanged. | Whatever you measure locally has to be re-measured where the job will actually run. |
| The markup moves | Amazon ships several product templates (books, electronics, subscriptions). A selector tuned on one silently returns None on another. |
Ongoing maintenance, plus alerting smart enough to tell "empty" from "broken". |
Worth knowing before you go shopping for a free alternative. Two things are worth checking on any repo before you clone it: does its own most recent output show a populated price, or only a README that says it does, and does running it require a third-party vendor's API key, which several of the popular ones do.
The managed option, and the one this repo is built around. The Chocodata Amazon Product Scraper API returns Amazon product prices, ratings, specifications and sales rank as parsed JSON for Amazon product data extraction at scale, with a ~99% success rate and no proxy management. Free for the first 1,000 requests.
What it adds over the script above:
- The rest of the page. The free script parses four fields. The endpoint returned 39 populated fields on the sample ASIN, including
price_strikethrough, the 12-key spec table, the category ladder, the sales rank, the star histogram and the seller. Most of that is not a harder selector, it is a different amount of work. - Coverage where the free path came back empty. In the survey above the Echo Dot ASIN returned no price to the free script on 5 of 5 fetches with a browser User-Agent. The endpoint read
49.99for it. - A pinned request.
countryfixes the egress instead of letting the answer depend on wherever your code runs, which matters more than it sounds: see the note onpricebelow. - Somebody else maintains the parser. Amazon ships several product templates and moves them. That is our on-call rather than yours.
Below is the Amazon Product Scraper API reference to get you started: authentication, the parameters, errors and rate limits, then the endpoint with its response.
curl "https://api.chocodata.com/api/v1/amazon/product?api_key=YOUR_KEY&query=0143127748"import requests
r = requests.get(
"https://api.chocodata.com/api/v1/amazon/product",
params={"api_key": "YOUR_KEY", "query": "0143127748"},
timeout=90,
)
d = r.json()
print(d["title"], d["price"], d["currency"], d["rating"])
# The Body Keeps the Score: Brain, Mind, and Body in the Healing of Trauma 14.15 USD 4.8After running the command, your terminal should look something like this:
Get a key at chocodata.com (1,000 requests, one-time, no card).
Pass api_key as a query parameter on every request. That is the whole auth model.
| Param | Type | Required | Default | Description |
|---|---|---|---|---|
api_key |
string | yes | - | Your Chocodata API key. |
query |
string (10 chars) | one of query/url |
- | The ASIN, e.g. 0143127748. This is an identifier, not a search term: a keyword returns 400. Lowercase input is upper-cased for you. |
url |
string (URL) | one of query/url |
- | A full Amazon product URL. The ASIN is read out of the /dp/{asin}, /gp/product/{asin}, /product/{asin} or /d/{asin} segment; a URL with no ASIN in it fails validation rather than being fetched. |
domain |
enum | no | com |
Marketplace TLD. One of com, co.uk, de, fr, it, es, nl, pl, se, ca, com.mx, com.br, com.au, co.jp, sg, in, com.tr, ae, sa, eg. Sets currency too. |
country |
string (ISO-2, uppercase) | no | - | Egress location for the request. Pin it when you want results that do not move with wherever your code happens to run. |
language |
string | no | - | Locale for the page, in xx_XX form, e.g. en_US, de_DE. |
render_js |
boolean | no | false |
Full browser render instead of plain HTTP. Costs 15 credits instead of 5. |
add_html |
boolean | no | false |
Also return the raw upstream HTML in html alongside the parsed JSON (debugging). |
screenshot |
boolean | no | false |
Capture a PNG of the rendered page. Implies a render, and costs 15 credits. |
Each request costs 5 credits (= 1 request). Responses are billed only on success (2xx).
query and url are two ways to name the same product and they agree: on 2026-07-20 both forms returned identical payloads for 0143127748 on 59 of 60 fields (both samples are committed, diff them). The one that differed was is_prime, which is covered below and does not hold still between two calls of the same form either.
A URL is not a fetch instruction. url=https://example.com does not scrape example.com under the Amazon schema, which is the failure mode worth checking for on any endpoint that takes a URL: it returns 400 invalid_params naming query as required, because no ASIN could be read out of it.
Nothing below is billed: you are only charged on a 2xx.
| Status | error code |
Meaning | Billed | What to do |
|---|---|---|---|---|
400 |
invalid_params |
A required param is missing or the wrong shape. The body names the exact path and constraint. |
no | Fix the query string. |
401 |
INVALID_API_KEY |
Key missing, unrecognised, or revoked. | no | Check api_key. Get one at chocodata.com. |
402 |
INSUFFICIENT_CREDITS |
Balance exhausted. | no | Top up or upgrade at chocodata.com. |
404 |
product_not_found |
Amazon returned 404: the ASIN is delisted, malformed, or never existed on this marketplace. retryable: false. |
no | Fix the ASIN or the domain. Retrying will not help. |
429 |
RATE_LIMITED |
Over the rate limit or your plan's concurrency. | no | Back off and retry; see Rate limits. |
502 |
target_unreachable |
Amazon refused every attempt for this request. retryable: true. |
no | Retry once after a few seconds. |
Two response shapes exist: auth and billing errors nest under error.code (uppercase), while scrape-layer errors are flat with a lowercase error string plus retryable.
The scripts in this repo map each documented status onto an actionable message, so a typo'd key does not hand you a stack trace:
A bad key, verbatim:
curl "https://api.chocodata.com/api/v1/amazon/product?api_key=totally_invalid_key_123&query=0143127748"{"error":{"code":"INVALID_API_KEY","message":"Api key not recognised."}}Omitting the key entirely gives you the hint instead, verbatim:
{"error":{"code":"INVALID_API_KEY","message":"Missing api_key query parameter.","hint":"Pass your key as ?api_key=<your_key> in the request URL."}}Passing something that is not an ASIN, verbatim. It names the field and the rule it broke, which is why the scripts can print one line instead of a traceback:
curl "https://api.chocodata.com/api/v1/amazon/product?api_key=YOUR_KEY&query=notanasin"{"error":"invalid_params","issues":[{"validation":"regex","code":"invalid_string","message":"ASIN must be 10 alphanumeric chars","path":["query"]}]}An unknown domain comes back the same way, and the body lists all 20 accepted marketplace values, so you can discover them from the API rather than from this table.
A dead ASIN returns 404 product_not_found with retryable: false, and the message confirms you were not charged. A 502 does not mean your input was wrong: it is retryable: true, it was not billed, and the same ASIN typically succeeds on the next attempt, which is what the scripts in this repo do.
Two limits apply at once, and the one that binds first is usually the rate limit, not the concurrency cap.
Rate limit: 120 requests per 60 seconds, per key (sliding window). That is a hard ceiling of 2 requests/second sustained, whatever your plan.
Concurrency: how many requests you may have in flight at once, which varies by plan:
| Plan | Concurrent requests |
|---|---|
| Free | 10 |
| Vibe | 30 |
| Pro | 50 |
| Custom | 100 to 500+ |
Exceed either and you get 429, not a queue. Every request is a synchronous GET: there is no webhook, callback, or async job to poll. The examples use timeout=90 because a request that runs into the bot check and is re-attempted takes longer than the median suggests.
Size against the rate limit rather than the concurrency figure. One call covers one ASIN, so a 500-product catalogue is 500 calls, or roughly 4.2 minutes at 120/minute, and a daily sweep of 5,000 ASINs is about 42 minutes. Keep your pool at or under your plan's concurrency and pace it to the rate limit:
from concurrent.futures import ThreadPoolExecutor
import requests
def one(asin):
r = requests.get("https://api.chocodata.com/api/v1/amazon/product",
params={"api_key": KEY, "query": asin}, timeout=90)
d = r.json() if r.ok else {}
return asin, d.get("price"), d.get("currency"), d.get("stock")
asins = ["0143127748", "B09B8V1LZ3"] # your catalogue here
with ThreadPoolExecutor(max_workers=10) as pool: # <= your plan's concurrency
for asin, price, currency, stock in pool.map(one, asins):
print(asin, price, currency, stock)One Amazon product page, parsed into 60 top-level fields: the buybox price and what it was struck through from, the rating and its star histogram, the spec table, the category ladder, the sales rank, the image list and the seller.
| Param | Type | Required | Default | Description |
|---|---|---|---|---|
query |
string (10 chars) | one of query/url |
- | The ASIN. |
url |
string (URL) | one of query/url |
- | A product URL containing an ASIN. |
domain |
enum | no | com |
Marketplace TLD, which also sets currency. |
curl "https://api.chocodata.com/api/v1/amazon/product?api_key=YOUR_KEY&query=0143127748"Real response. 20 of the 60 top-level fields, chosen to show one of each shape; description truncated where marked, and product_details cut to 4 of its 12 keys. Every value is verbatim (full sample):
{
"asin": "0143127748",
"asin_in_url": "0143127748",
"parent_asin": "B00G3LLR0C",
"url": "https://www.amazon.com/dp/0143127748?language=en_US",
"title": "The Body Keeps the Score: Brain, Mind, and Body in the Healing of Trauma",
"description": "#1 New York Times bestseller\n“Essential reading for anyone interested in understanding and treating traumatic stress…",
"bullet_points": null,
"brand": "by Bessel van der Kolk M.D. (Author) Format: Paperback",
"price": 14.15,
"price_strikethrough": 19,
"price_shipping": 0,
"currency": "USD",
"discount_percentage": 26,
"pricing_str": "New & Used (139) from $4.41$4.41",
"stock": "In Stock",
"rating": 4.8,
"reviews_count": 84491,
"rating_stars_distribution": [
{ "rating": 5, "percentage": 86 },
{ "rating": 4, "percentage": 10 },
{ "rating": 3, "percentage": 3 },
{ "rating": 1, "percentage": 1 }
],
"sales_rank": [{ "ladder": [{ "name": "Post-Traumatic Stress", "url": null }], "rank": 1 }],
"product_details": {
"publisher": "Penguin Books",
"publication_date": "September 8, 2015",
"isbn_13": "978-0143127741",
"print_length": "464 pages"
}
}price and price_strikethrough together are what most people come for, and the pair is worth more than either alone: price is the buybox number, price_strikethrough is the list price it is struck through from, and discount_percentage is Amazon's own rounding of the gap. A listing that quietly drops its strikethrough keeps the same buybox price while the advertised saving disappears, so a tracker watching only price reports that nothing happened.
A buybox price is a fact about a request, not about a product. On 2026-07-20 this ASIN read 12.39 to the free scraper running from a laptop in Lithuania and 14.15 through the endpoint, minutes apart. Both are real reads of the same page; Amazon does not serve one global number. Two consequences worth building around: pin country and keep it pinned, so today's number and last month's came from the same place, and store the price against the request that produced it rather than treating it as a property of the ASIN. The endpoint's own reads were stable across a run: 14.15 on all 8 calls in the stability run.
Seven things worth coding against on this endpoint, all of them visible in the committed samples:
-
rating_stars_distributiondoes not always have five entries. This ASIN returns four: 86, 10, 3 and 1 percent, and the 2-star bucket is simply not there. The percentages sum to 100, so treat a missing star as zero rather than assuming a fixed-length array. -
is_primedoes not hold still. Across 8 calls on this ASIN inside one minute it came backtrueon 5 andfalseon 3, whileprice,ratingandreviews_countwere identical on all 8 (the run). It reports whether a Prime badge was present in that particular response. Build onfeatured_merchant.is_amazon_fulfilled, which wastrueon all 8. -
imagescontains duplicates. All 5 entries on this ASIN are the same URL: Amazon lists one photo at several sizes, and the size suffix is normalised to a single high-resolution form on the way out. Callset()on it before you count or download anything. -
pricing_stris a raw string with Amazon's own artifacts in it."New & Used (139) from $4.41$4.41"carries the price twice, run together with no separator, which is what the page's markup yields. Regex the first match out of it rather than casting the field. -
brandis whatever the byline says. On a book that is"by Bessel van der Kolk M.D. (Author) Format: Paperback", author and binding included, because the byline is where Amazon puts the brand slot on media listings.manufacturermirrors it when the spec table has no explicit manufacturer row. -
parent_asinis usually not the ASIN you asked for. Here it isB00G3LLR0C, the grouping that ties the paperback, Kindle and audiobook editions together. If you are de-duplicating a catalogue, that is the join key. -
reviewsreturns review metadata, not review text. On this ASIN it carries 13 rows withrating,timestamp,is_verified,helpful_countandproduct_attributespopulated, andtitleandcontentnullon all 13. The rows also carry reviewer display names, and this repo does not republish individuals' names, so the array is the one thing emptied in the committed samples. Everything else in those files is exactly as returned.
Fields that come back empty on this ASIN and are worth knowing about before you build on them: bullet_points, variations, sns_discounts, product_overview, technical_details, whats_in_the_box and warranty_and_support. Books do not carry feature bullets or a variation twister; a listing in Electronics or Home fills several of them.
Runnable: amazon_product_api_codes/product.py
Watching a price move is the main reason people scrape Amazon product pages, so that use case is in the repo end to end rather than as a snippet. price_monitor.py polls a list of ASINs, stores every observation as a local dataset in SQLite, and prints what moved since the last run:
python amazon_product_api_codes/price_monitor.py 0143127748 B09B8V1LZ3That is a real first run: nothing to compare against yet, so it seeds the database and says so. Run it again after prices move and each change prints as a diff line (DOWN price 14.15 USD -> 12.99 USD, WAS-PRICE 19.00 USD -> -, REVIEWS 84,491 -> 84,530 (+39)).
It records price and price_strikethrough together for the reason above, and deliberately does not record is_prime, because a diff on a field that came back both true and false inside one minute reports changes that are not changes.
Export the history with one command:
sqlite3 -header -csv amazon_prices.db "select * from observations;" > out.csvOne API call per ASIN per run. 1,000 free requests covers a month of daily checks on 30 products.
Real end-to-end wall-clock, measured from a laptop against the live API on 2026-07-20. This includes the upstream fetch and the parse:
| Endpoint | Median | Range | n | Success |
|---|---|---|---|---|
/amazon/product |
3.9s | 3.5 to 5.4s | 8 | 8/8 |
Every timing is recorded per call in stability_survey.json.
Read the range, not just the median. An Amazon product page is a megabyte or three of HTML before anything is parsed, which is most of where the time goes, and a request that has to be re-attempted upstream is where the tail comes from. Budget a retry rather than a failure for the occasional 502, which is retryable and not billed. Small sample (n=8) from a single day; reproduce it yourself with the scripts here.
MIT. See LICENSE.







