How to Scrape Amazon Reviews With Python (2026)
Sep 20, 2026 · Guides · 17 min read
You can scrape Amazon reviews that are actually present in the HTML of a public product page with Python, Playwright, and Beautiful Soup. You cannot assume that every signed-out session will expose those reviews, even for the same ASIN. During our September 18 testing, one fresh browser context returned eight public reviews for an Echo Dot product, while later contexts returned an Amazon access challenge. A second product loaded normally but required sign-in to see its reviews.
This tutorial builds an Amazon review scraper around that variable behavior. The script validates the Amazon URL, renders the page, scrolls progressively, classifies challenge and sign-in states, parses unique review records, exports CSV and JSON, and reopens both files to verify the result. It does not inject account cookies, solve CAPTCHAs, or claim to retrieve a product’s complete review history.
The guide is intended for Python developers who can run a command-line script and inspect HTML. By the end, you will have a tested extractor for reviews visible on a public product page, plus clear stop conditions for layouts that do not expose review data.
What We Verified Before Writing This Guide
The primary successful run used this public product URL:
https://www.amazon.com/dp/B07FZ8S74R
The ten-character value after /dp/ is the ASIN. That run started in a fresh, signed-out browser context, used no proxy, received HTTP 200, found eight review containers, and exported eight unique records. The script then reopened both output files and verified that all eight records survived the round trip.
Amazon returned different states in other fresh contexts, so the tested result should be read as a compatibility matrix rather than a universal promise.
| Test target and state | Observed result | Correct scraper response |
|---|---|---|
B07FZ8S74R, successful headless run |
8 containers and 8 exported reviews | Export and validate the records |
B07FZ8S74R, later headed run |
“Continue shopping” access challenge | Stop and save evidence |
B07GBZ4Q68 |
Product page loaded, but the review area required sign-in | Stop; do not inject account cookies |
B08N5WRWNW |
Page Not Found | Mark the target invalid or unavailable |
Direct page-2 URL for B07FZ8S74R |
Page Not Found in the visible-browser check | Do not start a pagination loop |
This variability is the central lesson of Amazon review scraping: HTTP 200 and a recognizable product URL do not prove that review containers are present.

Fields Extracted by the Amazon Review Scraper
The downloadable script stores the following fields:
| Field | Behavior |
|---|---|
asin |
Required; extracted from a recognized Amazon product URL |
review_id |
Used for deduplication when present |
rating |
Parsed as a number; otherwise null |
title |
Preserved as displayed; otherwise null |
body |
Required for an exported review record |
date_text |
Original Amazon date text |
review_date |
ISO date for recognized en-US dates; otherwise null |
verified_purchase |
true when the badge is present; otherwise null |
helpful_votes |
Parsed count when displayed; otherwise null |
source_url |
Final product-page URL after navigation |
The scraper deliberately does not collect reviewer display names. They are unnecessary for this technical example and create avoidable privacy risk. It also keeps missing helpful-vote values as null, because “not displayed” is not the same as zero.
Prerequisites and Tested Versions
| Component | Verified version |
|---|---|
| Operating system | Windows build 26200.8037 |
| Python used for the current rerun | 3.12.14 |
| Playwright for Python | 1.63.0 |
| Beautiful Soup | 4.15.0 |
| lxml | 6.1.3 |
| Google Chrome | 152.0.7977.83 |
| Final script | 1.0.2 |
The primary eight-review live result was produced by script version 1.0.0. Version 1.0.1 added explicit classification for Amazon’s “Continue shopping” screen, recognized a review sign-in requirement, and normalized recognized en-US dates.
The delivery folder includes the live, replay, compatibility, and image-provenance reports. Raw browser captures and the saved source HTML are retained separately in the restricted editorial archive because they contain customer text and other material that should not be redistributed.
Create an isolated environment and install the pinned packages.
Windows PowerShell
python -m venv .venv
.\.venv\Scripts\Activate.ps1
python -m pip install -r requirements.txt
python -m playwright install chromium
macOS or Linux
python3 -m venv .venv
source .venv/bin/activate
python -m pip install -r requirements.txt
python -m playwright install chromium
The verified Windows environment reused the installed Chrome binary. Installing Playwright’s Chromium build is the portable fallback when Chrome is unavailable.
Step 1: Validate the Amazon URL and ASIN
Do not send an arbitrary user-supplied URL directly to an automated browser. The script permits only HTTP or HTTPS Amazon domains and requires a ten-character ASIN in a recognized path.
ASIN_RE = re.compile(
r"/(?:dp|gp/product|product-reviews)/([A-Z0-9]{10})(?:[/?]|$)", re.I
)
ALLOWED_AMAZON_MARKETPLACES = {
"amazon.com",
"amazon.ca",
"amazon.co.uk",
"amazon.de",
"amazon.fr",
"amazon.co.jp",
}
The complete script below contains the explicit marketplace list. It removes an optional www. prefix and compares the remaining hostname with that set, so lookalike hosts such as amazon.evil.com are rejected. Passing URL validation does not mean every marketplace layout or date format has been tested. The validated input is normalized to /dp/{ASIN} to remove tracking parameters, while the final browser URL is still recorded because Amazon may redirect or add a variant parameter.
Step 2: Inspect a Review Container
Open the product page, scroll to the review section, right-click one review, and choose Inspect. Start with the outer review container, then query fields inside that container. Scoping selectors to one review prevents a title from one record being combined with the date from another.

The current parser uses semantic data-hook attributes and keeps a small fallback list:
| Data | Primary selector | Fallback |
|---|---|---|
| Review container | div[data-hook="review"] |
div[id^="customer_review-"] |
| Rating | [data-hook="review-star-rating"] |
[data-hook="review-star-rating-view-point"] |
| Title | [data-hook="reviewTitle"] |
[data-hook="review-title"] |
| Body | [data-hook="reviewText"] |
[data-hook="reviewRichContentContainer"], then [data-hook="review-body"] |
| Date | [data-hook="review-date"] |
Return null |
| Verified purchase | [data-hook="avp-badge"] |
Return null |
| Helpful votes | [data-hook="helpful-vote-statement"] |
Return null |
Avoid generated class names such as _Y3Itd_...; they are much more likely to change than semantic hooks.

Step 3: Wait for Reviews Without Using a Blind Delay
The page may lazy-load content, so parsing immediately after DOMContentLoaded is unreliable. The script first waits for the document body and visible product title, then scrolls in finite viewport-sized steps. It stops scrolling as soon as a review container appears.
def scroll_until_reviews(page, max_steps=60):
reviews = page.locator('div[data-hook="review"], div[id^="customer_review-"]')
for _ in range(max_steps):
if reviews.count() > 0:
return True
page.evaluate(
"""
() => window.scrollBy(
0, Math.max(600, Math.floor(window.innerHeight * 0.75))
)
"""
)
page.wait_for_timeout(250)
reviews.first.wait_for(state="attached", timeout=15_000)
return True
The loop is bounded. If no review appears, the script classifies the page and writes diagnostic HTML and a screenshot instead of retrying indefinitely.
Step 4: Parse and Deduplicate the Reviews
Beautiful Soup parses the final rendered HTML. Each record is deduplicated by review_id; if no ID exists, the parser uses a SHA-256 hash of the title, body, and displayed date.
dedupe_key = review_id or hashlib.sha256(
f"{title}|{body}|{date_text}".encode("utf-8")
).hexdigest()
if dedupe_key in seen:
continue
seen.add(dedupe_key)
Only a missing review body causes a container to be skipped. Optional data stays nullable. The code retains both date_text and review_date because the original wording is useful for auditing, while an ISO date is easier to analyze. Date normalization is intentionally limited to the tested en-US format.
Complete Python script
The following is the single canonical implementation for this article. The same amazon_review_scraper.py file is included in the delivery folder; there is no separate shortened script with different behavior.
from __future__ import annotations
import argparse
import csv
import hashlib
import json
import os
import re
import sys
from dataclasses import asdict, dataclass
from datetime import datetime, timezone
from pathlib import Path
from typing import Any
from urllib.parse import urlparse
from bs4 import BeautifulSoup
from playwright.sync_api import (
Browser,
Page,
TimeoutError as PlaywrightTimeoutError,
sync_playwright,
)
SCRIPT_VERSION = "1.0.2"
ASIN_RE = re.compile(
r"/(?:dp|gp/product|product-reviews)/([A-Z0-9]{10})(?:[/?]|$)", re.I
)
ALLOWED_AMAZON_MARKETPLACES = {
"amazon.ae",
"amazon.ca",
"amazon.cn",
"amazon.co.jp",
"amazon.co.uk",
"amazon.com",
"amazon.com.au",
"amazon.com.be",
"amazon.com.br",
"amazon.com.mx",
"amazon.com.tr",
"amazon.de",
"amazon.eg",
"amazon.es",
"amazon.fr",
"amazon.ie",
"amazon.in",
"amazon.it",
"amazon.nl",
"amazon.pl",
"amazon.sa",
"amazon.se",
"amazon.sg",
}
REVIEW_SELECTORS = {
"container": ['div[data-hook="review"]', 'div[id^="customer_review-"]'],
"rating": [
'[data-hook="review-star-rating"]',
'[data-hook="review-star-rating-view-point"]',
],
"title": ['[data-hook="reviewTitle"]', '[data-hook="review-title"]'],
"body": [
'[data-hook="reviewText"]',
'[data-hook="reviewRichContentContainer"]',
'[data-hook="review-body"]',
],
"date": ['[data-hook="review-date"]'],
"verified": ['[data-hook="avp-badge"]'],
"helpful": ['[data-hook="helpful-vote-statement"]'],
}
@dataclass
class Review:
asin: str
review_id: str | None
rating: float | None
title: str | None
body: str
date_text: str | None
review_date: str | None
verified_purchase: bool | None
helpful_votes: int | None
source_url: str
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(
description="Export reviews visible on a public Amazon product page."
)
parser.add_argument(
"url",
nargs="?",
default="https://www.amazon.com/dp/B07FZ8S74R",
help="Public Amazon product URL containing a 10-character ASIN.",
)
parser.add_argument(
"--output-dir",
type=Path,
default=Path(__file__).resolve().parent / "output",
)
parser.add_argument(
"--check-pagination",
action="store_true",
help="Check one signed-out page-2 URL and report the observed access state.",
)
parser.add_argument(
"--capture-screenshots",
action="store_true",
help="Save unedited browser screenshots for verification.",
)
parser.add_argument("--headed", action="store_true")
return parser.parse_args()
def validate_target(url: str) -> tuple[str, str]:
parsed = urlparse(url)
if parsed.scheme not in {"http", "https"} or not parsed.hostname:
raise ValueError("Enter a complete HTTP or HTTPS URL.")
hostname = parsed.hostname.lower().rstrip(".")
marketplace = hostname.removeprefix("www.")
if marketplace not in ALLOWED_AMAZON_MARKETPLACES:
raise ValueError("The hostname must be an explicitly supported Amazon marketplace.")
match = ASIN_RE.search(parsed.path)
if not match:
raise ValueError("No 10-character ASIN was found in the URL path.")
asin = match.group(1).upper()
return asin, f"{parsed.scheme}://{hostname}/dp/{asin}"
def clean_text(value: str | None) -> str | None:
if value is None:
return None
cleaned = " ".join(value.split())
return cleaned or None
def first_text(node: Any, selectors: list[str]) -> str | None:
for selector in selectors:
match = node.select_one(selector)
if match:
value = clean_text(match.get_text(" ", strip=True))
if value:
return value
return None
def parse_rating(text: str | None) -> float | None:
match = re.search(r"([0-5](?:[.,]\d)?)", text or "")
return float(match.group(1).replace(",", ".")) if match else None
def parse_helpful_votes(text: str | None) -> int | None:
if not text:
return None
if re.search(r"\bone\s+person\b", text, re.I):
return 1
match = re.search(r"([\d,]+)", text)
return int(match.group(1).replace(",", "")) if match else None
def parse_review_date(text: str | None) -> str | None:
"""Normalize the en-US date while preserving Amazon's display text."""
match = re.search(r"\bon\s+([A-Za-z]+\s+\d{1,2},\s+\d{4})$", text or "")
if not match:
return None
try:
return datetime.strptime(match.group(1), "%B %d, %Y").date().isoformat()
except ValueError:
return None
def spreadsheet_safe(value: Any) -> Any:
"""Prevent spreadsheet software from evaluating untrusted text as a formula."""
if isinstance(value, str) and value.startswith(("=", "+", "-", "@")):
return "'" + value
return value
def review_id_from(container: Any) -> str | None:
html_id = clean_text(container.get("id"))
if html_id and html_id.startswith("customer_review-"):
return html_id.removeprefix("customer_review-")
review_link = container.select_one('a[href*="customer-reviews/srp/-/"]')
if review_link:
match = re.search(
r"/customer-reviews/srp/-/([A-Z0-9]+)", review_link.get("href", "")
)
if match:
return match.group(1)
return None
def parse_reviews(html: str, asin: str, source_url: str) -> tuple[list[Review], int]:
soup = BeautifulSoup(html, "lxml")
containers = []
for selector in REVIEW_SELECTORS["container"]:
containers = soup.select(selector)
if containers:
break
reviews: list[Review] = []
seen: set[str] = set()
for container in containers:
body = first_text(container, REVIEW_SELECTORS["body"])
if not body:
continue
review_id = review_id_from(container)
title = first_text(container, REVIEW_SELECTORS["title"])
date_text = first_text(container, REVIEW_SELECTORS["date"])
dedupe_key = review_id or hashlib.sha256(
f"{title}|{body}|{date_text}".encode("utf-8")
).hexdigest()
if dedupe_key in seen:
continue
seen.add(dedupe_key)
reviews.append(
Review(
asin=asin,
review_id=review_id,
rating=parse_rating(first_text(container, REVIEW_SELECTORS["rating"])),
title=title,
body=body,
date_text=date_text,
review_date=parse_review_date(date_text),
verified_purchase=(
True
if any(container.select_one(s) for s in REVIEW_SELECTORS["verified"])
else None
),
helpful_votes=parse_helpful_votes(
first_text(container, REVIEW_SELECTORS["helpful"])
),
source_url=source_url,
)
)
return reviews, len(containers)
def classify_page(page: Page, html: str) -> str:
sample = f"{page.title()}\n{html}".lower()
if re.search(
r"click the button below.{0,200}continue shopping", sample, re.DOTALL
):
return "continue_shopping_challenge"
if "robot check" in sample or "enter the characters you see below" in sample:
return "robot_check"
if "validatecaptcha" in sample or ("captcha" in sample and "amazon" in sample):
return "captcha"
if "/ap/signin" in page.url or (
"sign in or create account" in sample and 'name="email"' in html.lower()
):
return "sign_in"
if "page not found" in sample or "dogs of amazon" in sample:
return "not_found"
if re.search(r"sign in.{0,200}to see customer reviews", sample, re.DOTALL):
return "review_sign_in_required"
if re.search(r"/dp/[A-Z0-9]{10}", page.url, re.I):
return "product_page"
return "unexpected_page"
def launch_browser(playwright: Any, headed: bool) -> Browser:
options: dict[str, Any] = {"headless": not headed}
chrome = Path(r"C:\Program Files\Google\Chrome\Application\chrome.exe")
if chrome.exists():
options["executable_path"] = str(chrome)
proxy_server = os.getenv("ROLA_PROXY_SERVER")
if proxy_server:
options["proxy"] = {
"server": proxy_server,
"username": os.getenv("ROLA_PROXY_USERNAME", ""),
"password": os.getenv("ROLA_PROXY_PASSWORD", ""),
}
return playwright.chromium.launch(**options)
def review_containers_locator(page: Page):
return page.locator('div[data-hook="review"], div[id^="customer_review-"]')
def scroll_until_reviews(page: Page, max_steps: int = 60) -> bool:
review_locator = review_containers_locator(page)
for _ in range(max_steps):
if review_locator.count() > 0:
return True
state = page.evaluate(
"""
() => {
const step = Math.max(600, Math.floor(window.innerHeight * 0.75));
window.scrollBy(0, step);
return {
y: window.scrollY,
max: Math.max(0, document.documentElement.scrollHeight - window.innerHeight)
};
}
"""
)
page.wait_for_timeout(250)
if state["y"] >= state["max"]:
break
try:
review_locator.first.wait_for(state="attached", timeout=15_000)
return True
except PlaywrightTimeoutError:
return False
def save_browser_evidence(page: Page, output_dir: Path, name: str) -> None:
evidence_dir = output_dir / "browser_evidence"
evidence_dir.mkdir(parents=True, exist_ok=True)
(evidence_dir / f"{name}.html").write_text(page.content(), encoding="utf-8")
page.screenshot(path=str(evidence_dir / f"{name}.png"), full_page=False)
def export_and_verify(reviews: list[Review], output_dir: Path) -> dict[str, Any]:
rows = [asdict(review) for review in reviews]
spreadsheet_rows = [
{key: spreadsheet_safe(value) for key, value in row.items()}
for row in rows
]
json_path = output_dir / "amazon_reviews.json"
csv_path = output_dir / "amazon_reviews.csv"
json_path.write_text(json.dumps(rows, indent=2, ensure_ascii=False), encoding="utf-8")
fieldnames = list(Review.__dataclass_fields__)
with csv_path.open("w", newline="", encoding="utf-8-sig") as handle:
writer = csv.DictWriter(handle, fieldnames=fieldnames)
writer.writeheader()
writer.writerows(spreadsheet_rows)
with csv_path.open("r", newline="", encoding="utf-8-sig") as handle:
csv_rows = list(csv.DictReader(handle))
json_rows = json.loads(json_path.read_text(encoding="utf-8"))
if len(csv_rows) != len(reviews) or len(json_rows) != len(reviews):
raise RuntimeError("Round-trip validation failed: exported row counts differ.")
source_keys = [
row["review_id"]
or hashlib.sha256(
f"{row['title']}|{row['body']}|{row['date_text']}".encode("utf-8")
).hexdigest()
for row in rows
]
if len(source_keys) != len(set(source_keys)):
raise RuntimeError("Round-trip validation failed: duplicate review keys remain.")
if any(not row["body"] for row in csv_rows):
raise RuntimeError("Round-trip validation failed: an exported body is empty.")
return {
"csv_rows_reopened": len(csv_rows),
"json_rows_reopened": len(json_rows),
"unique_record_keys": len(set(source_keys)),
"round_trip_validated": True,
}
def check_public_page_two(
page: Page,
asin: str,
origin: str,
report: dict[str, Any],
output_dir: Path,
) -> None:
url = f"{origin}/product-reviews/{asin}/?pageNumber=2"
response = None
try:
response = page.goto(url, wait_until="commit", timeout=30_000)
page.locator("body").wait_for(state="attached", timeout=15_000)
page.wait_for_timeout(3_000)
html = page.content()
reviews, containers = parse_reviews(html, asin, page.url)
report.update(
{
"public_pagination_tested": True,
"pagination_http_status": response.status if response else None,
"pagination_final_url": page.url,
"pagination_page_type": classify_page(page, html),
"pagination_review_containers": containers,
"pagination_reviews_parsed": len(reviews),
"public_pagination_available": bool(reviews),
}
)
save_browser_evidence(page, output_dir, "pagination_page_2")
except PlaywrightTimeoutError:
report.update(
{
"public_pagination_tested": True,
"public_pagination_available": False,
"pagination_error": "navigation_timeout",
}
)
try:
save_browser_evidence(page, output_dir, "pagination_timeout")
except PlaywrightTimeoutError:
pass
def run() -> int:
args = parse_args()
args.output_dir.mkdir(parents=True, exist_ok=True)
asin, normalized_url = validate_target(args.url)
parsed_target = urlparse(normalized_url)
origin = f"{parsed_target.scheme}://{parsed_target.hostname}"
report: dict[str, Any] = {
"script_version": SCRIPT_VERSION,
"tested_at_utc": datetime.now(timezone.utc).isoformat(timespec="seconds"),
"python_version": sys.version.split()[0],
"target_url": normalized_url,
"asin": asin,
"access_state": "signed_out_fresh_context",
"proxy_configured": bool(os.getenv("ROLA_PROXY_SERVER")),
}
print(f"Script version: {SCRIPT_VERSION}")
print(f"Target ASIN: {asin}")
print(f"Opening: {normalized_url}")
with sync_playwright() as playwright:
browser = launch_browser(playwright, args.headed)
context = browser.new_context(
locale="en-US",
timezone_id="America/New_York",
viewport={"width": 1440, "height": 900},
)
page = context.new_page()
response = None
try:
response = page.goto(
normalized_url, wait_until="domcontentloaded", timeout=90_000
)
page.locator("body").wait_for(state="attached", timeout=15_000)
try:
page.locator("#productTitle:visible").first.wait_for(
state="visible", timeout=15_000
)
except PlaywrightTimeoutError:
pass
if args.capture_screenshots:
page.screenshot(
path=str(args.output_dir / "product_page_viewport.png"),
full_page=False,
)
review_loaded = scroll_until_reviews(page)
if review_loaded:
first_review = review_containers_locator(page).first
first_review.scroll_into_view_if_needed()
page.wait_for_timeout(1_000)
if args.capture_screenshots:
page.screenshot(
path=str(args.output_dir / "review_page_viewport.png"),
full_page=False,
)
review_area = page.locator("#customer-reviews_feature_div")
if review_area.count() and review_area.first.is_visible():
review_area.first.screenshot(
path=str(args.output_dir / "review_section.png")
)
html = page.content()
page_type = classify_page(page, html)
reviews, container_count = parse_reviews(html, asin, page.url)
report.update(
{
"http_status": response.status if response else None,
"page_title": page.title(),
"final_url": page.url,
"page_type": page_type,
"review_locator_loaded": review_loaded,
"review_containers_found": container_count,
"reviews_parsed": len(reviews),
"containers_not_exported": container_count - len(reviews),
"public_pagination_tested": False,
"public_pagination_available": None,
}
)
if page_type != "product_page" or not reviews:
save_browser_evidence(page, args.output_dir, "failed_product_page")
raise RuntimeError(
f"No usable public reviews were found; page_type={page_type}. "
"Inspect browser_evidence before retrying."
)
report["export_validation"] = export_and_verify(reviews, args.output_dir)
report["field_quality"] = {
"missing_review_id": sum(r.review_id is None for r in reviews),
"missing_rating": sum(r.rating is None for r in reviews),
"missing_title": sum(r.title is None for r in reviews),
"missing_body": sum(not r.body for r in reviews),
"missing_date_text": sum(r.date_text is None for r in reviews),
"missing_review_date": sum(r.review_date is None for r in reviews),
"missing_verified_purchase": sum(
r.verified_purchase is None for r in reviews
),
"missing_helpful_votes": sum(r.helpful_votes is None for r in reviews),
}
if args.check_pagination:
pagination_page = context.new_page()
try:
check_public_page_two(
pagination_page, asin, origin, report, args.output_dir
)
finally:
pagination_page.close()
finally:
(args.output_dir / "verification_report.json").write_text(
json.dumps(report, indent=2, ensure_ascii=False), encoding="utf-8"
)
browser.close()
print(f"HTTP status: {report.get('http_status')}")
print(f"Page type: {report.get('page_type')}")
print(f"Review containers found: {report.get('review_containers_found')}")
print(f"Reviews parsed: {report.get('reviews_parsed')}")
print(
"CSV round-trip validated: "
f"{report.get('export_validation', {}).get('round_trip_validated')}"
)
if args.check_pagination:
print(f"Pagination page type: {report.get('pagination_page_type')}")
print(
"Public pagination available: "
f"{report.get('public_pagination_available')}"
)
print(f"Saved: {args.output_dir.resolve()}")
return 0
if __name__ == "__main__":
try:
raise SystemExit(run())
except (ValueError, RuntimeError, PlaywrightTimeoutError) as exc:
print(f"ERROR: {exc}", file=sys.stderr)
raise SystemExit(2)
Step 5: Run the Scraper and Reopen the Output
Run the script from PowerShell:
python amazon_review_scraper.py `
"https://www.amazon.com/dp/B07FZ8S74R" `
--capture-screenshots `
--output-dir output
To make one bounded page-2 check, add --check-pagination. The check uses the same Amazon marketplace as the input URL, waits no more than 30 seconds for navigation to commit, and records the final page type. It does not continue when Amazon returns sign-in, a challenge, or a missing page.
On a successful run, the output directory contains:
amazon_reviews.csvamazon_reviews.jsonverification_report.json- optional raw browser screenshots
After writing CSV and JSON, the script immediately reopens them. It verifies row counts, unique record keys, and nonempty review bodies. Before writing CSV, text that begins with =, +, -, or @ is prefixed with an apostrophe so spreadsheet software does not evaluate untrusted review text as a formula. JSON preserves the original text. An export is not reported as successful merely because a file exists.

The successful run produced the following verified result:
HTTP status: 200
Page type: product_page
Review containers found: 8
Reviews parsed: 8
CSV rows reopened: 8
Unique record keys: 8
Round-trip validated: True
All eight records had an ID, rating, title, body, date, and verified-purchase badge. Two records had no displayed helpful-vote count, so those values remained null.
Step 6: Detect Challenge, Sign-In, and Missing Pages
An Amazon review scraper should identify content states before it trusts any selector. The script checks the final URL, page title, and HTML for these outcomes:
product_pagecontinue_shopping_challengerobot_checkcaptchareview_sign_in_requiredsign_innot_foundunexpected_page
The visible-browser test encountered this access challenge on the same ASIN that had succeeded in another fresh context:

A direct page-2 URL also failed during the current visible-browser check:

Optional Proxy Routing for an Authorized Test
For an authorized regional test, a web scraping proxy can keep the network route consistent with the marketplace being evaluated. For Amazon-specific routing requirements, review the Amazon proxy solution separately from the scraper implementation. A residential proxy may be appropriate when the project genuinely needs a residential exit region, but it does not grant access to login-only reviews and does not make an otherwise prohibited collection acceptable.
The script reads proxy settings from environment variables so credentials are not committed to source control:
$Env:ROLA_PROXY_SERVER="http://HOST:PORT"
$Env:ROLA_PROXY_USERNAME="YOUR_USERNAME"
$Env:ROLA_PROXY_PASSWORD="YOUR_PASSWORD"
python amazon_review_scraper.py "https://www.amazon.com/dp/B07FZ8S74R"
Use the official Python proxy integration documentation for the endpoint and authentication format assigned to your account. Never include real credentials, account cookies, or session tokens in screenshots.
Common Amazon Review Scraping Errors
| Symptom | Likely cause | How to verify | Correct response |
|---|---|---|---|
| HTTP 200 but zero reviews | Reviews are absent, lazy content did not load, or sign-in is required | Inspect the final HTML and screenshot | Scroll progressively once; then stop and record the state |
| Continue Shopping page | Amazon returned an access challenge | Match the visible message and final screenshot | Stop; do not automate challenge completion |
| Sign-in message in the review section | Reviews are gated for that context | Search HTML for “Sign in to see customer reviews” | Do not inject personal cookies |
| Page Not Found | Invalid, unavailable, or unsupported target URL | Check the final title, URL, and screenshot | Correct the ASIN or mark it unavailable |
Titles and bodies are all null |
Selector casing or structure changed | Inspect one current review container | Update the centralized selector list |
| Duplicate output rows | The same card loaded more than once | Compare IDs or content hashes | Deduplicate before export |
| Missing helpful-vote count | Amazon did not display one | Inspect the individual review | Keep null; do not convert it to zero |
| CSV exists but contains bad data | Export ran without quality checks | Reopen the file and compare row counts | Fail the run when validation does not pass |
These are content-level checks. A status code alone cannot distinguish a product page, sign-in screen, challenge, and error page.
Scope, Limitations, and Responsible Use
This example collects only review records displayed on a public product page in the tested browser context. When scraping Amazon reviews, treat every session as a new observation: this script does not promise complete coverage, stable selectors, or consistent results across regions, products, and Amazon experiments.
Before scraping Amazon customer reviews:
- Confirm that you are authorized to collect and use the data.
- Review Amazon’s current Conditions of Use and crawler directives.
- Treat
robots.txtas a technical directive, not a license or legal opinion. - Do not copy account cookies, purchase history, or login-only content into this workflow.
- Avoid collecting reviewer identities when they are unnecessary.
- Do not republish long review text or customer media without the required rights.
- Use finite timeouts, low request volume, bounded retries, and explicit stop conditions.
- Prefer a licensed source or written data agreement when completeness or redistribution rights matter.
This article provides technical guidance, not legal advice.
Conclusion
The reliable way to scrape reviews from Amazon is not to assume success. Validate the ASIN, render a fresh browser context, scroll in bounded steps, parse only review containers, deduplicate records, reopen the exports, and stop on sign-in, challenges, or missing pages.
That workflow produced eight verified records in the successful current run and also captured genuine failure states in later runs. Those failures are part of the result, not something to hide. If your authorized project needs stable regional routing, Rola IP can be evaluated as the network layer, while access rights and page availability must still be handled separately.