# Changelog of Amazon Reviews Scraper (`devilscrapes/amazon-reviews-scraper`) Actor

- **URL**: https://apify.com/devilscrapes/amazon-reviews-scraper/changelog.md
- **Full Actor documentation**: https://apify.com/devilscrapes/amazon-reviews-scraper.md

## Amazon Reviews Scraper — Changelog

### 0.7.0 — 2026-09-14

#### Fixed

- **The start fee is owed on delivery, not on boot.** `actor-start` ($0.20) was
  charged immediately after input validation — this Actor's own
  `pay_per_event.json` described it as *"charged once when the Actor run begins,
  before any fetch"*. At the 43.3% customer success rate that tripped
  UNDER_MAINTENANCE, roughly 17 of every 30 runs paid $0.20 for an empty
  dataset — and the failure they paid for was ours: blocking the `script`
  resource type stripped Amazon's client-injected review widget, so the page
  looked healthy and the run reported a convincing "0 reviews" (fixed in 0.6).

  `_StartFee` now fires at most once, and only from the first successful row
  push. A run where every ASIN is blocked now costs the customer nothing.

  Billing a start fee for a run that delivered nothing is the shape that got
  `vrbo-vacation-rentals-scraper` delisted at 17 runs.

  Note: the Store listing's own pricing copy still reads "before any fetch" —
  `pricingInfos` is append-only and a bad PUT breaks the whole listing, so that
  sentence needs a deliberate pricing-copy pass. The price itself is unchanged.

### 0.6 — 2026-09-14 (fix: `script`-blocking was silently stripping the review widget)

- **Root cause** (fleet daily report: LIVE customer success 43.3%, 13/30
  runs in 30d, flagged `UNDER_MAINTENANCE`; live build 0.5.3, no undeployed
  fix on `main`). This Actor's own `/acts/{id}/runs` history stopped at
  build 0.5.2 (2026-08-26) — no `recent_failures` covered the live build, so
  reproduced fresh against `build=latest` (live build 0.5.3) via `apify api`
  cloud runs: 3 single-ASIN reproductions on the shipped `input_schema`
  prefill (`asins: ["B0BSHF7WHW"]`) succeeded (5 and 10 real review rows), but
  a realistic multi-ASIN run (`tVT6rxKgkTNIac733`, two ASINs a customer
  might legitimately batch) **FAILED with "0 reviews" for BOTH** — including
  `B0BSHF7WHW` and `B09B8V1LZ3`, both independently confirmed to carry 13 and
  8 reviews by the 2026-08-26 Camoufox recon that shipped this Actor's
  current architecture. The run log showed a soft-block on attempt 0, a
  clean rotation, then total silence (no further warnings) before the
  aggregate `"no-results"` verdict for both ASINs — meaning the rotated
  fetch got a real, non-block 200 that `is_bot_block_page()` correctly did
  not flag (it had a title), but which carried zero
  `data-hook="review"` markup.
- **Confirmed via live capture**
  (`docs/specs/amazon-reviews-scraper/recon/degraded_page_probe.py`, local,
  Apify RESIDENTIAL, this Actor's exact production fetch config incl.
  `script` blocked): reproduced the exact page — a real 542 KB response, a
  real `productTitle`, a real `acrCustomerReviewText` rating-count widget
  (`aria-label="385 Reviews"`, so the product plainly has reviews) — but
  **zero `data-hook` attributes anywhere in the document**. The "Top
  reviews" widget the 0.5 pivot believed was server-rendered is
  client-side injected and needs JS to populate; that pivot's "5/5 clean
  with `script` blocked" validation (2026-08-26) was on too small a sample
  and never had a committed recon script, so the actual failure mode
  (Amazon serving the JS-dependent variant) went undetected until it
  dominated the public success rate. Saved as
  `tests/fixtures/review_page_degraded.html`.
- `src/client.py::BLOCKED_RESOURCE_TYPES`: `script` removed — only
  `image`/`media`/`font`/`stylesheet` stay blocked (pure cost, no review
  content lives there). `RENDER_SETTLE_MS` raised `2_000` -> `4_000` to give
  the now-unblocked client-side hydration time to run before `page.content()`
  is read. This reintroduces the ~10-14 MB/ASIN residential transfer the 0.5
  pivot cut to save cost — **flagged for a human to reconsider pricing on,
  not addressed here** (pricing is out of scope for a fixer pass; a
  non-delivering Actor already earns nothing, so restoring delivery is
  strictly better even before that's revisited).
- `src/parser.py::is_bot_block_page`: gained a third, defense-in-depth
  signal — a title AND a positive review count from the (non-JS-dependent)
  `acrCustomerReviewText` widget AND zero rendered review blocks is now
  treated as a degraded/unusable render (soft block), not a genuinely
  review-less product, so the caller rotates and retries instead of
  silently accepting it and failing the ASIN/run. A genuinely review-less
  product has no rating-count widget at all
  (`tests/fixtures/review_page_empty.html`), so this can't misfire on a
  real zero-review product — directly implements
  DEVILSCRAPES-CONTEXT.md's "find a positive signal for really empty"
  rule for the ambiguous-200 case.
- New tests: `tests/test_client.py::test_script_not_blocked` (pins `script`
  stays out of `BLOCKED_RESOURCE_TYPES` so a future cost pass can't
  silently reintroduce this), `tests/test_parser.py::
  test_is_bot_block_page_true_on_degraded_page_with_review_count_but_no_blocks`
  (fails on pre-fix code — confirmed red before the parser change, green
  after). 84 tests green, ruff clean (`check` + `format --check`; also
  cleared 3 files of pre-existing line-length drift unrelated to this fix —
  `src/models.py`, `src/scraper.py`, `tests/test_scraper.py` — needed for a
  green `ruff format --check` gate), pyright clean.
- Verified via cloud QA run `pbS8id34BhUjEyR7h` (build 0.6.1, shipped
  `tests/fixtures/input.qa.json`): SUCCEEDED, 5/5 real review rows
  (rating/review_text/review_date/reviewer_name/verified_purchase/
  helpful_vote_count all populated), `chargedEventCounts` matched delivery
  exactly. Re-exercises the exact B0BSHF7WHW page that failed "0 reviews"
  on live build 0.5.3 — see `docs/specs/amazon-reviews-scraper/notes.md`
  2026-09-14 08:46 entry for the full record.

### 0.5 — 2026-08-26 (pivot: curl-cffi -> Camoufox — fingerprint, not JS, was the wall)

- **Root cause** (30-day public stats: 20% success, 8/10 customer runs
  FAILED; `recent_failures` empty — this Actor's own runs weren't among
  them). The 2026-08-25 triage (build 0.4.1) fixed a real fault-isolation
  bug but left the Actor non-delivering: Amazon soft-blocked residential
  exits 5/5 on two ASINs, and no NO-GO could be written because
  `_ensure_residential` made the datacenter tier untestable from the
  Actor's own input. This entry establishes the actual verdict named in
  both tiers.
- **Tier probe** (`scripts/recon/tier_probe.py`, local, direct proxy-URL
  construction bypassing `_ensure_residential` entirely — unambiguous
  tier attribution since there's no fallback layer at that boundary):
  RESIDENTIAL and BUYPROXIES94952 (datacenter), 2 ASINs x 4 attempts each,
  pinned `country-US`. **0 of 16 attempts reached real review content on
  either tier.** Worse: of the 2/16 that got a genuine 200 (not a
  bot-check interstitial — real title, real page), **both had zero
  `data-hook="review"` markup** — a real page missing the widget, not a
  block. Confirmed independent of proxying: an unproxied local fetch got
  the identical stripped page.
- **Camoufox probe** (`scripts/recon/camoufox_probe.py`, local, Apify
  RESIDENTIAL pinned `country-US`, pattern copied from
  `actors/idealista-property-scraper/src/recon_camoufox.py` commit
  `89a78ce`): the same URL, same proxy pool, fetched by a real browser
  instead of curl-cffi, returns the reviews **server-rendered in the
  initial document response** — no JS execution needed to populate them.
  3/3 clean fetches on ASIN `B0BSHF7WHW` (13 reviews each), 1/1 on
  `B09B8V1LZ3` (8 reviews) — a genuinely dead ASIN (`B08N5WRWNW`, 404 even
  unproxied/no-proxy) was ruled out as the cause of the earlier "second
  ASIN" blocked-reading in the 2026-08-25 notes. **Verdict: GO** — this is
  a client-fingerprint problem (Amazon serves a different `document` by
  TLS/H2 fingerprint), not an anti-bot IP block and not a JS-rendering
  requirement.
- **Cost discipline**: a full JS-rendered page load costs ~10-14 MB of
  residential transfer per ASIN (mostly Amazon's own AUIClients bundle
  chunks) — at $8/GB that is ~$0.08-0.11/ASIN, which loses money against
  this Actor's $0.0025/review price on any multi-ASIN run. Since the
  review markup is already in the *first* document response, blocking
  `script` at the route-handler level (alongside the existing
  image/media/font/stylesheet block) was validated live (5/5 clean
  fetches, same review counts) and cuts per-ASIN transfer to ~1.5-2 MB
  (~$0.012-0.016/ASIN, ~$0.0009-0.002/review) — profitably under the
  $0.0025/review price even for multi-ASIN runs.
- `src/client.py`: `AmazonReviewSession` is now Camoufox-based —
  `AsyncCamoufox` launch (static `locale="en-US"`, no `geoip=True` per
  the documented crash mode in
  `actors/vrbo-vacation-rentals-scraper/src/browser.py`), a route
  handler blocking `image`/`media`/`font`/`stylesheet`/`script`, and the
  same retry/rotate state machine as before (408/429/5xx retry,
  403/429/soft-block-200 rotate, 400/401/404/410 fail loud) except
  rotation now relaunches the browser on a freshly-minted proxy session
  instead of swapping a curl-cffi impersonation profile. `resolve_proxy_url()`
  (REQ-8, `WEBSHARE_PROXY_URL` env-first) is unchanged.
- `src/main.py::_build_session`: awaits the new `AmazonReviewSession.create()`
  async factory instead of a synchronous constructor (browser launch is
  inherently async).
- `.actor/Dockerfile`: `apify/actor-python-playwright:3.11` base image +
  `python -m camoufox fetch` build step, structural copy of
  `actors/vrbo-vacation-rentals-scraper/.actor/Dockerfile`.
- `pyproject.toml`: `curl-cffi` dropped, `camoufox[geoip]` + `playwright` added.
- `.actor/actor.json`: memory raised (`512/2048/512` -> `1024/4096/2048`
  min/max/default) — a headless browser needs materially more headroom than
  a bare HTTP client; matches `actors/vrbo-vacation-rentals-scraper`'s
  Camoufox memory profile. Version bumped `0.4` -> `0.5`.
- `tests/test_client.py`, `tests/test_main.py`, `tests/test_smoke.py`:
  rewritten to mock/drive the Playwright `Page` surface instead of a
  curl-cffi session; `test_smoke.py`'s opt-in live check now exercises
  Camoufox directly (single non-retrying navigation, matching the old
  test's intent). `test_scraper.py`/`test_parser.py`/`test_models.py`
  untouched (fake-session/pure-parser, no interface change). 80 hermetic
  tests green (1 smoke test opt-in, unchanged), ruff clean, pyright
  clean, `verify_input_prefill.py` / `verify_no_scaffold_stub.py` /
  `verify_entrypoint_runs.py` all green.

### 0.4 — 2026-08-25 (fix: network error on a rotated-in exit never rotated again)

- **Root cause** (30-day public stats as of the 2026-08-25 fleet report: 42%
  success, 15 of 26 customer runs FAILED; deployed build 0.3.2): `recent_failures`
  for this Actor were empty (all customer-side), so reproduced directly with two
  fresh `apify api` cloud runs against the live build — `t1Ju4E9EVIorWMJOZ` and
  `cbASLiLAYFYrWR1qC` — both FAILED with the identical log signature: attempt 0
  hits an HTTP-200 soft block and correctly rotates, then attempts 1-4 all fail
  with `curl: (7) CONNECT tunnel failed, response 590` on the exit that rotation
  just minted, exhausting all 5 attempts and failing the ASIN (0 rows -> whole
  run FAILED). `scripts/os/run_usage.py` confirms both runs genuinely moved
  bytes over `PROXY_RESIDENTIAL_TRANSFER_GBYTES` — this is not a proxy-tier
  regression (RESIDENTIAL was actually used, not silently downgraded to
  datacenter); it's a fault-isolation gap in the client's retry loop.
- `src/client.py::AmazonReviewSession._attempt`: the network-error branch
  (`RequestException`/`OSError`, which is how curl-cffi surfaces a dead/blocked
  proxy tunnel) logged, slept, and retried on the **same session** — unlike the
  403/429 and soft-block-200 branches, it never called `self._rotate()`. A dead
  tunnel is a property of the exit IP, not the request, so retrying without
  rotating just re-dials the same broken exit until retries run out. Fixed to
  rotate (fresh browser profile + fresh Apify proxy `session_id`) on network
  error too, matching the existing rotate-on-block paths.
- `tests/test_client.py`: added `test_network_error_triggers_rotation` and
  `test_persistent_network_error_exhausts_retries_and_rotates_each_time`
  (mirrors the existing 403/429 rotation tests); also fixed
  `test_retry_then_success_after_network_error` to mock `_rotate`, since it
  now exercises a code path that constructs a real `curl_cffi.AsyncSession` —
  under this test suite's known process-wide `asyncio.sleep` monkeypatch (see
  the 0.3 CHANGELOG entry's "Bonus bug"), that briefly hung the test run.
  82 tests green (1 smoke test skipped, opt-in), ruff clean, pyright clean.
- An earlier branch, `fix/amazon-reviews-scraper-network-error-no-rotate`,
  named this exact root cause but carried zero commits beyond `origin/main` —
  checked via `git merge-base --is-ancestor`, confirmed empty, superseded here.

### 0.3 — 2026-08-06 (fix: geo-random residential exit silently served a review-less page)

- **Root cause** (30-day public stats: `{FAILED: 6, SUCCEEDED: 1}`, 14%
  success, failures still accumulating as of the 08-03/08-05 snapshots):
  compared two cloud QA runs from build 0.2.1 that already ran the
  0.1/0.2 fixes — `wCnILYz9DB0d7e4MK` (FAILED, `statusMessage: "Scraped
  0 reviews across 1 ASIN(s): B0BSHF7WHW (no-results)"`) and
  `TiDTHYXnIXMOPwZGI` (SUCCEEDED, 5 real review rows) — same ASIN, same
  build, three minutes apart. The failing run's log shows exactly one
  `soft block (HTTP 200) — rotating session` line, then a real,
  non-block 200 response that `is_bot_block_page()` correctly does
  *not* flag (it has a `productTitle`) but that embeds **zero**
  `data-hook="review"` blocks — a real Amazon page, just missing the
  Top-reviews widget. The succeeding run needed 3 rotations before
  landing on a working exit and returned 5 rows for the identical
  ASIN. The only thing that varies run-to-run is which residential exit
  gets minted; per today's cross-actor finding (bestbuy.com
  country-selector splash, Viator JPY pricing), **residential proxy
  exits are geo-random**, and `amazon.com` renders a different,
  reviews-less layout for a non-US-recognized visitor without ever
  tripping a bot-check marker. The actor never pinned an exit country,
  so ~1 in a few rotations silently produced this false-empty result
  instead of erroring.
- `src/main.py::_resolve_proxy_configuration`: now calls
  `Actor.create_proxy_configuration(groups=..., country_code="US")`
  instead of passing the raw `proxyConfiguration` input straight
  through — every request is pinned to a US residential exit
  (`PROXY_COUNTRY_CODE`), and RESIDENTIAL is force-included
  (`_ensure_residential`) even if a caller's `proxyConfiguration` input
  omits it. `marketplace_domain` is a single-value `"amazon.com"` enum
  in v1, so hardcoding US is in-scope, not a workaround.
- `tests/test_main.py`: two new tests pin that
  `_resolve_proxy_configuration` always passes `country_code="US"` and
  always includes `"RESIDENTIAL"` in `groups`, regardless of what the
  input's `proxyConfiguration` dict contains.
- 80 tests green (incl. `test_proxy_session_id_matches_apify_regex`,
  Gate 9's regression test), ruff clean, pyright clean,
  `scripts/verify_input_prefill.py` re-verified.
- Verified end-to-end with a real cloud run against the pushed build —
  see the publish/QA log for the run id and a sample dataset row.
- **Publish-gate backfill** (pre-existing gaps, unrelated to the root
  cause above, blocking `/publish-actor`'s Gate 1 and Gate 7): added
  `.actor/icon.png` (rendered from the existing `icon.svg` via
  `qlmanage`, matching `scripts/upload_actor_icons.py`'s fallback
  path) and `tests/test_smoke.py` (opt-in `-m smoke`, live fetch of
  ASIN `B0BSHF7WHW`'s product page, asserts parseable reviews) — this
  actor had neither before, unlike the `google-ads-transparency`
  reference implementation.
- **Bonus bug found while adding the smoke test**: `tests/conftest.py`'s
  autouse `_patch_sleep` fixture called
  `monkeypatch.setattr(client_mod.asyncio, "sleep", ...)` —
  `client_mod.asyncio` is the real, global `asyncio` module object (a
  Python import doesn't copy it), so this silently replaced
  `asyncio.sleep` **process-wide** for every test, not just calls made
  from `src.client`. That starves curl-cffi's async transport (which
  depends on genuine event-loop scheduling to service a live socket),
  turning a real network fetch into an indefinite CPU-spinning hang
  instead of a clean timeout — reproduced directly: the first draft of
  `test_smoke.py` hung >3 minutes at ~60% CPU with this patch active,
  and passed in under 2s once the fixture was scoped to skip
  `smoke`-marked tests. Fixed via `request.node.get_closest_marker
  ("smoke")`. All 12 existing retry/backoff tests in `test_client.py`
  still pass unaffected (they're not smoke-marked).

### 0.2 — 2026-08-02 (fix: bare/default-input runs hard-failed REQ-3)

- **Root cause** (CEO report 2026-08-02: "3 of last 4 runs failed"):
  pulled the actual run logs for the two `recent_failures` at build
  0.1.1 (`vvSBwVsHWFjaF44YX`, `PImgd1oHcBK0rI5pT`, both started
  2026-08-01 after the 0.1 soft-block fix was already live) via
  `apify runs info` — both failed in 6-11s with `statusMessage:
  "Invalid input: 1 validation error for ActorInput. Value error,
  Provide at least one of asins or product_urls."`, before any HTTP
  request was made. The run's own `INPUT` KVS record showed every
  field at its `input_schema.json` **`default`** — `asins: []`,
  `productUrls: []` — not the `prefill` value (`["B0BSHF7WHW"]`).
  `prefill` only backs the interactive Console form; a run started
  without customizing every field (bare API call, integration,
  platform health-check) gets the schema's `default`, and
  `asins.default` had drifted to `[]` while `asins.prefill` stayed a
  real ASIN — so any such invocation hard-failed REQ-3 immediately.
  This is a distinct bug from the 0.1 soft-block fix (confirmed via
  fresh log pulls, not assumed) and was invisible to
  `scripts/verify_input_prefill.py`'s existing Gate 3c because that
  check only reconstructs the `prefill` payload, never `default`.
- `.actor/input_schema.json`: `asins.default` now matches
  `asins.prefill` (`["B0BSHF7WHW"]`) instead of `[]` — a bare/default
  invocation now runs a real (small, demo) scrape instead of failing
  loud on empty input. `ActorInput`'s REQ-3 cross-field validator is
  unchanged: a customer who explicitly clears both fields and submits
  still gets a clear validation error, as intended.
- `tests/test_models.py`: new `test_schema_default_input_is_runnable`
  reconstructs the exact payload Apify merges for an un-customized
  run (every property's schema `default`, mirroring the real
  `vvSBwVsHWFjaF44YX`/`PImgd1oHcBK0rI5pT` INPUT records) and pins that
  it must pass `ActorInput.model_validate` — regression guard for this
  default/prefill drift class, since Gate 3c alone doesn't catch it.
- 23 tests green, ruff clean, pyright clean, `verify_input_prefill.py`
  re-verified against the updated schema.
- **Follow-up for a human** (not touched by this fix, pre-existing and
  out of scope): the 2026-08-01 fix commit already flagged that live
  Console PPE pricing uses Apify's automatic
  `apify-actor-start`/`apify-default-dataset-item` events, not this
  repo's custom `pay_per_event.json` events — `main.py`'s
  `Actor.charge()` calls remain no-ops on the live platform pending
  reconciliation.

### 0.1 — 2026-08-01 (fix: HTTP-200 soft block + RESIDENTIAL proxy default)

- **Root cause** (30-day public stats: 0% success, 3/3 failed; PPE
  pricing went live 2026-08-01): reproduced directly via `apify api`
  cloud runs against build 0.0.1 — of 3 fresh runs on the same ASIN,
  one FAILED with `statusMessage: "Scraped 0 reviews across 1 ASIN(s):
  B0BSHF7WHW (no-results)"` while the other two SUCCEEDED. Amazon
  (PerimeterX-class anti-bot, flagged as a risk at build time in
  `marketing/opportunities/SCRAPER-BACKLOG-2026-07-29.md` item #1)
  intermittently soft-blocks the Actor's proxy exit IPs: it answers
  with HTTP 200, not a 403/429, so `client.py`'s existing retry/rotate
  logic — which only branches on status code — never engaged. The
  blocked page has no `productTitle` and no `data-hook="review"`
  blocks, so `parse_review_page` correctly found zero reviews and
  `main.py` correctly failed a run with a genuinely empty result — the
  bug is that a *soft block* was silently treated as "this product has
  no reviews" instead of "retry with a fresh session", exactly as
  `client.py`'s original module docstring called out as scraper.py's
  responsibility but which was never wired up.
- `src/parser.py`: adds `is_bot_block_page()` — detects Amazon's
  bot-check interstitial via known text markers
  (`BOT_BLOCK_TEXT_MARKERS`, industry-documented, not recon-captured
  this session — flagged for re-verification against a live capture)
  plus a structural fallback (no product title AND no review blocks;
  a real product page always has a title, even with zero reviews).
- `src/client.py`: `AmazonReviewSession.get_page()` now accepts an
  optional `is_blocked` predicate; a 200 response it flags rotates the
  proxy session + browser profile and retries, identical to the
  existing 403/429 path, instead of being returned as real content.
- `src/scraper.py`: wires `parser.is_bot_block_page` into
  `session.get_page(...)`.
- `src/models.py`, `.actor/input_schema.json`: `proxyConfiguration`
  default/prefill now includes `apifyProxyGroups: ["RESIDENTIAL"]` —
  Amazon is an anti-bot-prone target per
  `DEVILSCRAPES-CONTEXT.md`'s anti-blocking-stack rule; the actor
  previously defaulted to Apify's datacenter proxy group.
- New tests: `tests/test_client.py` (soft-block rotate+retry,
  exhausted-retries FetchError, backward-compat no-predicate path),
  `tests/test_parser.py` (`is_bot_block_page` true/false cases incl.
  a real zero-review product staying `False`), `tests/test_scraper.py`
  (regression pin that `is_bot_block_page` is actually wired into
  `get_page`).

### 0.0.2 — 2026-07-30 (scope pivot after cloud recon)

- **Cloud recon result**: `amazon.com/product-reviews/<ASIN>` (the
  originally-planned all-reviews listing page) 200s but silently
  redirects to `amazon.com/ap/signin` — it is login-walled, not
  anti-bot-walled (no CAPTCHA/JS-execution challenge; confirmed via
  `r.url` after redirect and inspecting the sign-in HTML). No
  curl-cffi impersonation profile change fixes an auth gate.
- **`amazon.com/dp/<ASIN>` (the product page) is not login-walled** —
  200, no redirect, and it embeds a real, keyless "Top reviews from
  the United States" widget (`data-hook="review"` blocks; 8 observed
  on a live sample fetch, `data-hook="total-review-count"` showing
  \~196k total ratings the widget does not paginate through over plain
  HTTP).
- **Pivoted scope**: this actor now scrapes the top reviews embedded
  on `amazon.com/dp/<ASIN>` (typically 6-10 per ASIN), not a full
  paginated review corpus. This is a narrower but real, live-confirmed
  surface — see `docs/specs/amazon-reviews-scraper/spec.md` "Wire
  format" for the full pivot rationale.
- `src/parser.py`: selectors are now live-confirmed (not placeholders)
  against a real fetch; fixed `TITLE_CSS` (`h5[data-hook="reviewTitle"]`,
  not `a[data-hook="review-title"]`) and `BODY_CSS`
  (`div[data-hook="reviewRichContentContainer"]`, not
  `span[data-hook="review-body"]`) to match real markup. All other
  selectors matched the original hypothesis exactly.
- `src/scraper.py`: removed the `/product-reviews/` pagination loop
  (`sortBy`/`pageNumber`, `MAX_PAGES_PER_ASIN`); now a single fetch of
  `amazon.com/dp/<ASIN>` per ASIN. Dedupe (REQ-10) kept as a defensive
  guard against duplicate markup on one page.
- `src/models.py`: dropped `sort_by`/`sortBy` (only meaningful for the
  removed pagination); `max_reviews_per_product` ceiling lowered from
  2000 (default 100) to 20 (default 10) to reflect the real, smaller
  surface — no longer misleading about corpus size.
- `.actor/input_schema.json`, `README.md`: updated to describe "top
  reviews from the product page" honestly instead of a full,
  paginated corpus; dropped the `sortBy` input field.
- Test fixtures replaced: `tests/fixtures/review_page_page1.html` /
  `review_page_empty.html` now mirror the real `/dp/<ASIN>` markup
  structure confirmed via live recon (data-hooks, tag names, nesting)
  with anonymized review text/names — not blind synthetic guesses.
- 68 tests green, ruff clean, `verify_input_prefill.py` re-verified
  against the updated `input_schema.json`.

### 0.0.1 — 2026-07-30

- Scaffolded: `.actor/` (actor.json, input/output/dataset schemas, PPE
  events, Dockerfile, icon), `src/__init__.py` + `src/__main__.py`
  entry point, `tests/conftest.py` + `tests/fixtures/input.qa.json`,
  README skeleton.
- `src/models.py`, `src/client.py`, `src/parser.py`, `src/scraper.py`,
  and `src/main.py` are not implemented yet — see
  `docs/specs/amazon-reviews-scraper/tasks.md` (T02-T11).
- Mandatory cloud recon (`src/recon_cloud.py`, orchestrator-owned,
  never shipped) has not yet confirmed the wire-format hypothesis in
  `docs/specs/amazon-reviews-scraper/spec.md` ("Wire format") — parser
  selectors are placeholders pending that recon.
