# Changelog of Reverb Scraper — Sold Listings & Price Guide (`devilscrapes/reverb-sold-listings`) Actor

- **URL**: https://apify.com/devilscrapes/reverb-sold-listings/changelog.md
- **Full Actor documentation**: https://apify.com/devilscrapes/reverb-sold-listings.md

## Changelog — reverb-sold-listings

### \[0.8.0] — 2026-09-09

#### Fixed — query mode restored (root cause: wrong `operationName`, not an upstream outage)

- **Root cause found via real-browser HAR capture.** Three prior
  investigations (2026-08-26, 2026-09-01, and this same day's
  `ops/qa/reverb-sold-listings-2026-09-09.md`) all confirmed `cspSearch`
  returns `total > 0` with `csps: null`, but none could go further because
  Reverb's GraphQL introspection is disabled and nobody had captured what
  `reverb.com`'s own frontend actually sends. This session used the fleet's
  existing Camoufox install (`ops/foxright/.venv`, no proxy needed locally)
  to open `https://reverb.com/price-guide`, search "gibson les paul", and
  record every request/response to `gql.reverb.com`. Raw capture committed
  at `ops/qa/reverb-har-2026-09-09/`.
- **The frontend sends `operationName: "Core_SellFlow_Search"` — not
  `"PriceGuideSearch"`,** the locally chosen label this Actor's `CSP_SEARCH_OPERATION`
  constant had sent since the 0.4.0 GraphQL rewrite (2026-07-11; the
  module's own comments already correctly documented "operation
  `Core_SellFlow_Search`" as what the live page fires — the constant just
  never matched that documentation). A systematic isolation harness
  (`ops/qa/reverb-har-2026-09-09/isolation_probe_operation_name.py`) proved
  `operationName` is the *only* thing that matters: byte-identical query
  text and variables return `csps: null` under the wrong operation name and
  a real, populated list under the correct one — reproduced on two
  independent search terms. Every other candidate (selection-set fields,
  `excludedCategoryUuids`, `sort`, `fuzzy`, `listingsThatShipTo`,
  `hasExpressSaleBid`, `withAggregations`, `boostByClicks`, the
  `@include(if:)` directive) was individually ruled out by controlled
  probes — none of them changed the result.
- **This means the ~2026-08-21 break was never a genuine Reverb outage** —
  `gql.reverb.com` appears to gate the `cspSearch.csps` list behind an
  operation-name allowlist (an anti-scraping signal), while `total` (a
  cheaper/ungated resolver) kept answering regardless, producing the exact
  "match count real, list null" signature every prior investigation saw.
- `src/client.py`: `CSP_SEARCH_OPERATION` changed to `"Core_SellFlow_Search"`;
  `CSP_SEARCH_QUERY` rewritten to declare that operation name and the
  variable names/shape the real operation expects (`sellCardLimit` instead
  of `limit`, `fullTextQueryOperand` as a variable instead of a literal,
  `includePriceRecommendations` + `priceRecommendationCountryCode` added —
  required only because the real operation declares them, not because they
  affect whether `csps` resolves). The existing `_raise_if_search_unreadable`
  fail-loud guard (0.6.0) is untouched and stays as defense-in-depth.
- Regression tests: `tests/test_client.py`
  (`test_iter_csp_entries_sends_real_frontend_operation_name` pins the
  operation name constant; `test_iter_csp_entries_parses_real_captured_response_shape`
  replays a fake response built from the actual HAR capture, fixture
  `tests/fixtures/csp_search_response_real_operation_name.json`) and fixed
  `test_iter_csp_entries_page_limit_never_exceeds_cap`, which asserted the
  now-retired `variables["limit"]` key. 84 hermetic tests pass (was 82, +2
  net new).

#### Also fixed — publish-gate cleanup found while shipping this fix

- `.actor/input_schema.json`'s `query` field description said query mode
  "fails loudly... Use guideIds until Reverb restores it" — stale now that
  it's fixed; rewritten to describe the mode neutrally.
- `README.md` Pricing section and FAQ still priced examples off the
  retired `$0.05` `actor-start` fee (the live fee has been `$0.20` flat
  since 2026-08-20, see memory `reference-ppe-generic-vs-custom-events`);
  a 20-guide run is `$0.30` (was shown as `$0.15`) and a single-guide poll
  is `$0.205` (was shown as "5.5 cents") — both understated actual cost.
- Fixed 3 pre-existing `pyright` `reportOptionalMemberAccess` errors in
  `tests/test_empty_result_succeeds.py` and `tests/test_search_unreadable.py`
  (unrelated to this fix, but blocking Gate 7) — `MagicMock.await_args` is
  typed `Optional`, so assert it's not `None` before indexing `.args`/`.kwargs`.
- Added `tests/test_proxy_session_id.py` — a regression pinning that
  `_resolve_proxy_url`'s constructed `session_id` matches Apify's required
  `^[\w._~]+$` (no hyphens), which had no test coverage despite being a
  documented hard rule (DEVILSCRAPES-CONTEXT.md "Anti-blocking stack" §2).
- Reinstated `tests/test_smoke_live.py` (`pytest -m smoke`) — CHANGELOG
  0.4.0 documented one but it no longer existed in this worktree. Covers
  both modes live: `query` mode is the one that just broke silently for
  \~3 weeks because nothing exercised it outside the (guideIds-only) cloud
  QA fixture between publishes.
- `README.md` FAQ ("What happens when zero estimates are found?") still
  described the pre-0.5.0 `SystemExit(1)` behavior — reverted since
  2026-08-19 (see "\[0.5.0]" below) — as current. Rewritten to match the
  actual, live behavior: the run SUCCEEDs with a status message, and only
  `actor-start` is charged, not `result-row`. Also added the already-true
  but undocumented `productType` accepted-but-ignored note to Limitations.

### \[0.7.0] — 2026-09-01

#### Investigated

- **The flagged 30-day signal (78% customer success, 29/134 FAILED) is real
  on the current build, not a stale-window artifact.** Build 0.6.1 deployed
  2026-08-26T09:04:29Z, 6 of the 30 trailing days at investigation time — but
  unlike the 08-26 signal, this one does not predate the build; it needed
  root-causing on its own.
- **`cspSearch` (query mode) is still broken upstream.** Re-probed live
  2026-09-01: `gql.reverb.com` still returns `{"total": 1407, "csps": null}`
  for `fender stratocaster` — the identical signature found 2026-08-26. The
  0.6.0 fail-loud fix is working exactly as designed here: query-mode
  customers get an honest FAILED run instead of a silent zero-row "Done".
  This is upstream-degraded, not a bug in this Actor, and remains blocked on
  a real-browser HAR capture of Reverb's current live query shape (out of
  this session's toolset — see the 0.6.0 entry below).
- **Found a second, independent, code-side bug: guideIds mode had no
  per-item fault isolation.** `_guide_id_rows` looped over `cfg.guide_ids`
  and awaited `fetch_estimated_value` with no `try`/`except` — any transport
  failure that survived `_post_graphql`'s own retries on ONE id (a proxy
  hiccup on the small `BUYPROXIES94952` datacenter pool, a transient 5xx that
  outlasts backoff) propagated straight out of the loop, through `_run`, and
  crashed the entire `async with Actor:` block — discarding rows already
  fetched for every OTHER id in the same run and reporting a bare FAILED
  with a raw traceback. This is the fleet's #1 recurring failure class (see
  memory `reference-fleet-fault-isolation-pattern`, 5-for-5 evidence
  2026-08-05) and it had never been applied to this Actor's guideIds path.
- **A second, related gap in `_post_graphql` itself: connection-level
  faults were never retried.** The retry loop only handled HTTP-status-based
  failures (408/429/503) — a connection-level exception raised by
  `session.post` (proxy reset, DNS blip, TLS handshake failure) was not
  caught anywhere in the loop, so it propagated on the *first* attempt
  instead of getting the same backoff+retry every HTTP-level fault gets.
  This made query mode's search call and every guideIds fetch more fragile
  than REQ-4 ("retry on network errors") actually specifies.

#### Fixed

- `src/client.py` — `_post_graphql` now catches
  `curl_cffi.requests.exceptions.RequestException` around the `session.post`
  call and retries with the same exponential backoff as HTTP-status
  failures, instead of letting a connection-level fault propagate
  unretried on the first attempt. Extracted `_handle_graphql_response` to
  keep `_post_graphql` under the 40-line function ceiling.
- `src/main.py` — `_guide_id_rows` now wraps the per-id
  `fetch_estimated_value` call in `try`/`except RuntimeError`: a failed id
  is logged, appended to a new `errored_ids` list, and skipped (`yield
  None`) instead of raising. `_run` now returns `(kept, attempted,
  errored_ids)`. `main()` distinguishes two new outcomes:
  - **Partial failure** (some ids errored, at least one succeeded) —
    still a SUCCEEDED run; the status message names how many ids were
    skipped (`_done_message`), so the charge for those attempts isn't
    silently unaccounted for.
  - **Total failure** (every attempted id errored, `kept == 0`) — a new
    `_all_guides_unreachable` check fires `Actor.fail` with
    `GUIDE_IDS_UNREADABLE_MESSAGE`, an upstream/network-scoped message.
    This is deliberately distinct from the existing "kept == 0 → genuine
    no-match, SUCCEED" branch (CHANGELOG 0.5.0): reaching that branch used
    to mean "Reverb answered and had nothing to publish", but an all-errored
    batch means Reverb never actually answered for any of the requested
    ids — reporting that as a quiet zero-row success would repeat the exact
    silent-non-delivery mistake 0.6.0 fixed for query mode, just in the
    other mode.
- Regression tests: `tests/test_client.py`
  (`test_post_graphql_retries_connection_error_then_succeeds`,
  `test_post_graphql_raises_after_max_retries_on_persistent_connection_error`),
  `tests/test_guide_id_fault_isolation.py` (per-id isolation, pinned at the
  `_guide_id_rows` level), and
  `tests/test_main_guide_ids_partial_and_total_failure.py` (the two new
  `main()` branches end-to-end). 82 tests pass (was 70 pre-fix collectable,
  +12 net new).

#### Not changed

- Query mode's fail-loud behavior (0.6.0) is untouched — re-confirmed live
  2026-09-01 that Reverb's `cspSearch` is still serving `csps: null`, so
  this remains the correct, honest outcome for query-mode traffic until
  Reverb's search recovers or someone captures the current live query shape
  via browser HAR (Camoufox, per the 0.6.0 entry).

### \[0.6.0] — 2026-08-26

#### Investigated

- **The flagged 30-day signal (82% customer success, 24/140 FAILED) predates
  this build.** `/v2/acts/{id}/runs` shows only 2 of our own runs in the
  window, both SUCCEEDED (QA runs `q3fjZsSfSsJKiFeXa` 08-19, `5s2PDzPZYAHQsUZav`
  08-21). Current build 0.5.2 (deployed 2026-08-21T07:11Z) already carries the
  0.5.0 "empty search succeeds" fix, live for 7 of the 30 trailing days. The
  24 FAILED runs match that exact pre-fix signature ("zero timeouts" =
  ordinary logic exits, not transport errors) and almost certainly happened
  before 2026-08-19. No code change made for this signal — reproducing it
  would be inventing a fix for a stale problem.

#### Found instead — NEW, currently-active defect (not yet fixed live)

- **`cspSearch` (query mode's only data source) now returns `csps: null` on
  every request, regardless of proxy tier, browser-impersonation profile, or
  header set.** Confirmed via 10+ live probes 2026-08-26: 6 cloud runs
  through the Actor itself (`fender stratocaster`, `gibson les paul`, `moog
  synthesizer`, `roland juno`, `yamaha piano`, `guitar` w/ `maxGuides=200`)
  all reported "0 guide(s) inspected" despite `total` in the 1000s; direct
  GraphQL probes against `gql.reverb.com` (local, Apify RESIDENTIAL proxy,
  Apify datacenter proxy, chrome131/firefox147/safari180 impersonation,
  cookie-warmed and cookieless, minimal and full field selections) all
  returned `{"data":{"cspSearch":{"total":N,"csps":null}}}` — never an
  `errors` array, never a non-2xx status. Introspection is disabled
  server-side, so the current live query shape (if csps still exists at all)
  could not be found without a real-browser HAR capture, which is out of
  this session's toolset (no Camoufox/DevTools access here).
- **This is a break in the last 5 days, not a stale issue.** The 0.5.1 QA run
  (`q3fjZsSfSsJKiFeXa`, 2026-08-19) and 0.5.2 QA run (`5s2PDzPZYAHQsUZav`,
  2026-08-21) both used `query` mode and both returned real rows ("Done — 3
  price-guide estimate row(s) emitted from 3 guide(s) inspected"). Something
  on Reverb's side broke `cspSearch` between 2026-08-21T07:11Z and
  2026-08-26.
- **The live build (0.5.2) currently masks this as a false SUCCESS.** The
  0.5.0 "empty search succeeds" logic (`kept == 0` → status message + return)
  cannot distinguish "the target answered and had nothing to publish" from
  "the target never delivered a result list at all" — every `query`-mode
  customer run right now is charged `actor-start`, reports "Done — 0
  results," and delivers zero rows, silently. This does not show up as
  FAILED in `publicActorRunStats30Days`, so the fleet-health dashboard
  reads this Actor as healthy while its primary search mode is fully
  non-functional. `guideIds` mode is unaffected (still fetches real
  estimates — verified live 2026-08-26, 2 of 4 explicit ids returned rows).

#### Fixed

- `iter_csp_entries` (`src/client.py`) now distinguishes a genuinely empty
  search (`total: 0`, `csps: []` or `null`) from an unreadable one (`total >
  0` on page one, `csps: null`) and raises a new `SearchUnreadableError` on
  the latter instead of silently returning zero rows. Only checked on the
  first page — a null `csps` after real rows already came back on an
  earlier page is ordinary pagination exhaustion, not breakage.
- `main.py` catches `SearchUnreadableError` specifically and calls
  `Actor.fail(status_message=...)` with a customer-facing message: Reverb's
  search API is the one returning unreadable results (not the customer's
  query), and `guideIds` mode is unaffected and still works. This is an
  explicit, deliberate FAILED run — the honest alternative to charging
  `actor-start` and silently reporting "Done — 0 results" for a search that
  never actually ran. See "Decision to ship" below.
- Regression tests: `tests/test_client.py`
  (`test_iter_csp_entries_raises_when_total_nonzero_but_csps_null`,
  `test_iter_csp_entries_null_csps_with_zero_total_is_genuinely_empty`,
  `test_iter_csp_entries_null_csps_on_later_page_ends_pagination_quietly`)
  and `tests/test_search_unreadable.py`
  (`test_search_unreadable_fails_loud_with_upstream_scoped_message`,
  `test_search_unreadable_still_charges_actor_start_first`). Verified both
  ways: fail on the pre-fix code, pass on post-fix.

#### Changed

- **`tests/fixtures/input.qa.json` switched from `query` mode to `guideIds`
  mode** (`[1404, 1021]`, `useProxy: true`) — the mandatory cloud-QA gate
  (`actor-qa-engineer`) reads this file, and query mode is provably
  upstream-broken right now (see below), so gating publish on it would
  permanently block every future release until Reverb fixes their API. The
  retired query-mode input is preserved at
  `tests/fixtures/input.query-mode-known-broken.json` for diagnostic runs.
  `.actor/input_schema.json`'s customer-facing prefill (`query: "fender
  stratocaster"`) is untouched — this only changes what the automated gate
  itself exercises.

#### Charge-order finding

`actor-start` is charged before the network call that reveals whether
Reverb's search is readable — readability can only be known *after* that
request, so the check cannot move ahead of the charge without redesigning
what `actor-start` pays for (out of scope; see `ops/os/CHARGE-BEFORE-VALIDATE-2026-08-18.md`).
Input validation already runs before the charge (unaffected by this fix).
Failing loud after a charge still beats succeeding silently before one.

#### Decision to ship

Initially left this fix on-branch, unpushed, because `apify run` against
the real live Reverb API correctly raises the new error — meaning cloud QA
on the *query-mode* fixture would legitimately fail, converting 100% of
query-mode traffic from a silent 0-row "Done" into an explicit FAILED run.
CEO override 2026-08-26: ship it anyway. Every query-mode customer run was
already paying `actor-start` and receiving zero rows while being told
"Done" — silent zero-delivery on a monetized, 9-user Actor is the exact
failure class `ops/os/EMPTY-IS-NOT-A-FAILURE-2026-08-19.md` was written
about, and a visible FAILED rate is the honest signal, strictly better than
invisibly billing people for nothing. `guideIds` mode is unaffected and is
now what the QA gate — and any future automated fleet sweep — exercises.

#### Known limitation — query mode remains degraded upstream

`cspSearch`'s `csps` field returns `null` for every request against
`gql.reverb.com` (confirmed across proxy tiers, browser-impersonation
profiles, and header sets 2026-08-26) while `total` still populates.
Introspection is disabled server-side, so the current live query shape (if
one still exists) needs a real-browser HAR capture of `gql.reverb.com` —
Camoufox is proven on this fleet (cleared DataDome on idealista the same
day) and is the tool for that capture when someone picks this up. Until
then, query-mode customers get an honest FAILED run pointing them at
`guideIds` mode instead of a silent empty success.

### \[0.5.0] — 2026-08-19

#### Fixed

- **An empty result set no longer FAILS the run.** A search that matched nothing exited 1 (spec REQ-7, "fail-loud on zero rows"). That charged the customer `actor-start` and *then* handed them a FAILED run, and Apify counts those against the Actor's public success rate. 24 of the last 156 customer runs (30d) were FAILED with zero timeouts — the signature of ordinary no-match queries. Every transport/HTTP error already raises out of `_post_graphql` after its retries, so reaching zero rows means Reverb answered and simply publishes no estimate for the query. It now finishes SUCCEEDED with a status message naming what was inspected. This exact pattern already cost this Actor a delisting once — see 0.3.0 below, "exited 1 on every run (including Apify's automated daily QA), which unlisted it from the Store".
- **The test suite has been red since 2026-07-11 and now runs again.** `tests/test_client.py` and `tests/test_parser.py` still imported the pre-0.4.0 REST surface (`PAGE_SIZE_GUIDES`, `_get_with_retry`, `fetch_priceguide_page`, `iter_priceguide_entries`, `_has_more_pages`, `matches_product_type`, `parse_guide_meta`) — all deleted in the 0.4.0 GraphQL rewrite. Both modules failed at *collection*, so the whole suite errored out and any publish gate running pytest would have refused this Actor. Rewritten against the live `cspSearch`/`priceguide` surface: 70 tests pass (was 0 collected).

#### Added

- `tests/test_empty_result_succeeds.py` — pins the no-match-succeeds behaviour, that the happy path still reports `Done`, and that invalid input still fails *before* `actor-start` is charged.
- `tests/fixtures/csp_search_entry.json` — a real-shape `cspSearch` CSP entry with two condition tiers, so the widest-band estimate logic is covered.

### \[0.4.0] — 2026-07-11

#### Fixed

- **query mode was dead — Reverb retired the public REST price-guide search.** `GET /api/priceguide?query=` now returns HTTP 403 ("This endpoint is no longer publicly available"), so every free-text `query` run failed while `guideIds` runs still worked (~90% overall success). The Actor was flagged "Under maintenance" and unlisted. Root-caused via browser/HAR capture of the live price-guide page — see `docs/specs/reverb-sold-listings/notes.md`.

#### Changed

- **query mode now uses Reverb's `cspSearch` GraphQL** (`gql.reverb.com`, operation `Core_SellFlow_Search`) — the exact search the reverb.com price-guide page fires. Each match (a Canonical Sell Page) carries its estimated value range inline (`priceRecommendations`), so query mode is now a **single call** with no per-guide follow-up. Output columns are unchanged; the estimate is the widest band across Reverb's used-condition tiers, forced to USD (`countryCode: US`).
- **guideIds mode is untouched** — still fetches Reverb's `priceguide` estimate from `rql.reverb.com`. Verified live (ids `1404`, `1021`).
- **`query`-mode `guide_id` is now a Canonical Sell Page (CSP) ID** — a different ID space from the legacy price-guide node IDs `guideIds` mode accepts (Reverb runs them as separate systems). Documented in README + dataset schema. `year`/`finish` are `null` in query mode (guides are model-family level, spanning finishes).
- **`productType` input deprecated** — Reverb's current search API exposes no category field. The input is accepted for back-compat but ignored, with a warning logged. Refine the `query` instead.

#### Added

- Opt-in live smoke tests (`pytest -m smoke`, `tests/test_smoke_live.py`) covering both modes against the real Reverb APIs — run before every publish.

### \[0.3.1] — 2026-06-01

#### Meta

- Pushed v0.3 to Apify; cloud build tagged 0.3.1. Added `.actorignore` (was missing from earlier commit). QA PASS: 20 estimate rows emitted (29 guides inspected), PPE charges `actor-start:1` + `result-row:20` confirmed. "Under maintenance" flag cleared by successful run.

### \[0.3.0] — 2026-06-01

#### Fixed

- **"Under maintenance" flag — the data source was gutted.** Reverb retired the public `/api/priceguide/{id}/transactions` endpoint: it now returns `{"total":0,"transactions":[]}` for **every** guide, so the actor emitted zero rows and exited 1 on every run (including Apify's automated daily QA), which unlisted it from the Store. The legacy individual price-guide web pages now 301 to a search-only landing, and `/api/listings?state=sold` silently ignores the filter and returns live inventory — so per-transaction sold rows are no longer obtainable publicly.

#### Changed

- **New data source: Reverb's GraphQL estimated value.** Stage 2 now queries `rql.reverb.com` (`priceguide(input:{id})`) for each guide's `priceLow` / `priceHigh` — Reverb's published value range, which it computes directly from recent sold listings ("We look at sold listings… based on how much gear in the same condition has sold for on Reverb recently"). Stage 1 (the REST price-guide search) is unchanged.
- **Output shape: one row per price guide** carrying its estimated value range (`estimate_low_*` / `estimate_high_*` / `estimate_currency`) plus make/model/year/finish/product\_type, instead of one row per historical transaction. The `marketplace_url` column links to a Reverb marketplace search for the gear (current live listings) since individual guide pages are gone.
- **Input:** dropped `maxListings` (a transaction cap with no meaning under the new model); `maxGuides` now caps emitted estimate rows. The Actor over-fetches search entries to still reach `maxGuides` rows after skipping the ~20% of guides without a published estimate.

### \[0.2.0] — 2026-05-20

#### Fixed

- Add `prefill` to the discriminating input field so Apify's automated daily QA receives a runnable payload. Empty-input runs were tripping the Pydantic `model_validate` XOR/required-field check inside 100 ms, which after three consecutive days flagged the Actor "Under maintenance" and unlisted it from the Apify Store.
