Double Rewards

Rotating IPs:Buy 50GB, get 10GB free

Static IPs:Get 7 extra days for every 30 days purchased, plus 500MB per order

Buy Now >

Security AlertA fraudulent phishing site ( rola-ip.com) is impersonating our previous official website. ROLA IP's only official website is rola-ip.co (make sure it uses the .co domain suffix). To protect your funds, do not make payments on any other domain!

How to Scrape Amazon Reviews With Python (2026)

Daniel Zhao

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.

The live Amazon product page used for the successful test, with the target product and signed-out state marked

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.

Amazon review card showing rating, title, date, and review body fields with personal text redacted

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.

Chrome DevTools open beside the saved DOM from a verified live Amazon run

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.csv
  • amazon_reviews.json
  • verification_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.

PowerShell output showing eight parsed reviews and validated CSV and JSON row counts

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_page
  • continue_shopping_challenge
  • robot_check
  • captcha
  • review_sign_in_required
  • sign_in
  • not_found
  • unexpected_page

The visible-browser test encountered this access challenge on the same ASIN that had succeeded in another fresh context:

A Chrome window showing Amazon's Continue Shopping challenge

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

A Chrome window showing Page Not Found for the direct review page-2 URL

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.txt as 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.

FAQs