Quick answer: Chairish's search box isn't one endpoint. A query like q=chairs — a known category name — 302-redirects away from /search entirely, landing on a curated /collection/chairs?redirect_type=name page. A query like q=mid century desk — free text with no matching category — stays on /search and returns a plain 200 with the results embedded directly. Same input box, same result-card markup once you're looking at the HTML, but two structurally different server responses depending on what you typed. A scraper that assumes "the search URL" is one stable route will work for half its queries and silently misbehave on the other half.
The search that isn't always a search
We built the fetch layer to hit https://www.chairish.com/search?q=<query> for every query in the input list — the obvious approach, and it's what Chairish's own UI does when you type into the box. Before writing the pagination loop, we ran a handful of real queries through it to see what actually came back.
chairs didn't return a search-results page. It came back as a 302 to /collection/chairs?redirect_type=name — a completely different URL, on a completely different route, that Chairish maintains as a curated landing page for that category. mid century desk, on the other hand, has no matching category, so it stayed on /search?q=mid%20century%20desk and returned a 200 with the product grid inline. Two queries, two response shapes, and nothing in the input told us which one we'd get ahead of time — it depends entirely on whether Chairish recognizes the term as a category name.
The reason this doesn't break the Actor is boring in the best way: curl-cffi follows redirects by default, and — this is the part we actually verified rather than assumed — both response shapes carry the identical div.js-product-grid div.js-product card markup once the HTML lands. We diffed the two page structures against a captured q=chairs response and a captured q=mid century desk response before trusting that a single parser could handle both, rather than trusting that "it's the same site" meant "it's the same markup." It does, here. But that's a fact we checked, not a fact we assumed, and it's the reason the client module never special-cases either path — one fetch_search_page function, one card selector, two silently different routes underneath it.
The page number that also isn't what it looks like
While we were poking at the search route we checked the obvious follow-up: does an explicit ?page=1 behave the same as leaving the parameter off? It doesn't, quite — Chairish 301-redirects a literal page=1 back to the param-less URL. So the client only ever adds page= for page 2 and beyond, skipping a redirect round trip on every single first-page fetch instead of eating it on every query.
We also pushed past the end of a real result set — page=999 on a query that only has a handful of pages — to see what an anti-bot wall or a genuine end-of-results signal would look like. It came back as a plain 403 with no challenge body and no cookie set: a page-bound rejection from the CDN/Varnish layer for a page number that doesn't exist, not a fingerprinting response. That's why 403 sits in this Actor's retriable status set alongside 408/429/5xx — treating it as retriable lets a genuine transient hiccup recover, while a query that's actually exhausted its real pages still stops cleanly because find_cards returns nothing on the pages that do resolve.
What ends up in a row
Once the HTML lands, there's no JSON blob to evaluate and no API to reverse-engineer — every product card carries its own data directly as data-* attributes on the card element (data-product-id, data-product-title, data-product-price, data-taxonomy, data-dealer-guid, data-is-on-public-sale). That's the parser's whole job: read attributes off a node, not parse a script tag or evaluate anything.
The one attribute worth double-checking before you trust it: data-product-price ships as whole dollars, not cents. We cross-checked it against the page's own rendered price for one real listing — a "Vintage Leopard Velvet Swivel Chairs" listing showing $7,450 on the page had data-product-price="7450" in the markup, not 745000. Get that wrong — treat it as cents like plenty of commerce sites do — and every price in the dataset is off by 100x, cleanly and without a single error to catch it.
We also pinned the card selector to the results-grid wrapper (div.js-product-grid div.js-product) with an unscoped fallback (div.js-product) behind it, so a markup refresh that drops the grid class doesn't silently zero out every query — it falls back instead of failing.
What we handle so you don't have to
Live recon on Chairish's search pages found no anti-bot fingerprinting, so the default proxy tier is Apify's shared datacenter pool rather than residential — no reason to pay for more than the target asks for. That doesn't mean the retry stack is decorative: we still rotate Chrome and Firefox TLS fingerprints via curl-cffi impersonation on every attempt, retry with exponential backoff on 403/408/429/5xx up to five times per page honoring Retry-After, and isolate faults per listing so one malformed card never drops the rest of a page. A query that genuinely matches nothing still finishes as a clean, successful zero-row result instead of a failure — the run tells you what it searched, not just that it stopped.
🪑 Chairish Furniture & Decor Listings Scraper turns keyword or category searches against Chairish's vintage furniture, art, and antiques marketplace into clean, typed rows — price (whole dollars, decimal-normalized), taxonomy category, dimensions, seller GUID, and listing URL — instead of you opening the site and copying cards by hand. $4.00 per 1,000 results (a flat $0.20 start fee plus $0.0038 per item), and you only pay for rows that land.
FAQ
Does ?q=<query> always return a search-results page?
No. Category names Chairish recognizes 302-redirect to a curated /collection/<slug> page; free-text queries with no matching category stay on /search and return a 200 with results inline. Both shapes carry the same product-card markup, so one parser handles either — but a scraper that assumes /search never redirects will miss the category-name case entirely.
Is data-product-price in cents or dollars?
Whole dollars. We cross-checked it against a listing's own rendered price ($7,450 shown on the page, data-product-price="7450" in the markup) before trusting it — treating it as cents would silently multiply every price by 100.
Why does the Actor treat a 403 as retriable instead of failing?
A page=999 recon fetch past a query's real result count came back as a plain 403 with no challenge body and no cookie — a page-bound rejection, not an anti-bot signal. Retrying lets a genuine transient hiccup recover; a query with no more real pages still stops cleanly once a page returns zero cards.
Why does the default proxy use Apify's shared datacenter pool instead of residential?
Live recon on Chairish's search pages found no anti-bot fingerprinting, so datacenter is the honest default — you can switch proxy groups in the input if you see blocks at scale, but paying for residential by default when the target doesn't ask for it isn't how we price this one.
Top comments (0)