Pull Reverb's sold-listing-based estimated value range for any musical-gear search via the Reverb price-guide API — used-price lows and highs plus make, model, year, finish — export to JSON or CSV. One row per price guide. No login; we handle the retries. Used guitar price data.
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.05actor-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 pyrightreportOptionalMemberAccess 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 (
yieldNone
) 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 beforeactor-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.