DEV Community

Devil Scrapes
Devil Scrapes

Posted on

FurnishedFinder Scraper: caps every search at 50, and the fix isn't in the docs

Quick answer

FurnishedFinder's public search API hard-caps every response at exactly 50 listings — confirmed across 6 of 6 test metros, regardless of viewport size or density — and there is no page, cursor, after, offset, limit, or first argument on the search field to page past it. The obvious GraphQL fix — a $loc: SearchRequestLocationInput! variable, the type name the API's own field-error introspection implies should exist — returns a clean 400 Unknown type. The query that actually works passes the location filter as an inline literal in the query text, not a variable at all. We tile the requested map into recursive quadrants instead, dedupe every listing by ID across overlapping tiles, and ship the result for $4.20 per 1,000 listings.

Why does every FurnishedFinder search return exactly 50 results, no more, no less?

Because the cap is server-side and absolute — not a default page size with a knob to turn. FurnishedFinder's own search page calls api-public.prod.furnishedfinder.com/graphql internally, and we probed that same endpoint across 6 metros of very different density. Every single one came back at 50 results the moment the requested viewport was wide enough to contain 50+ listings. Narrow the box and you get fewer; nothing ever returns more.

There's also no argument to ask for more. The search field's schema has no page, cursor, after, offset, limit, or first parameter anywhere in it. A cap with no page parameter isn't an oversight — it's a design that will quietly under-report a dense metro like Austin or Houston at whatever fits in one viewport, with nothing in the response telling you that's what happened.

Why did the "obvious" GraphQL fix return a 400 instead of more data?

Because the fix that field-error introspection implies doesn't exist. FurnishedFinder disables schema introspection, so the only way to learn its input shapes is to read the errors it hands back on a deliberately malformed query. Doing that for the location filter surfaces a type named SearchRequestLocation — which, by the GraphQL variable convention almost every other API follows, should have an …Input sibling for declaring it as a reusable query variable: SearchRequestLocationInput.

It doesn't. This declaration:

query($id: String!, $loc: SearchRequestLocationInput!) {
  search(searchSessionId: $id, location: $loc) { results { listingId } }
}
Enter fullscreen mode Exit fullscreen mode

gets you a clean, honest 400 Unknown type "SearchRequestLocationInput" — the server correctly telling you the type you guessed doesn't exist. The query that actually returns 200 and real listings passes the whole location: {viewport: {...}} object as an inline literal inside the query string itself, with no corresponding variable declared at all:

query($id: String!) {
  search(searchSessionId: $id, location: {viewport: {
    max: { latitude: 30.5, longitude: -97.6 }
    min: { latitude: 30.1, longitude: -98.0 } }}) {
    results { listingId name propertyType bedroomCount bathroomCount }
  }
}
Enter fullscreen mode Exit fullscreen mode

A GraphQL API that names its error types by convention can still refuse the variable that same convention would predict. Confirm the working shape against a live 200, never against what the naming pattern implies should exist.

What happens when a metro has more than 50 listings in it?

We split the viewport into four quadrants and re-query each one recursively — the same shape as bisecting a price range to get past a different platform's row cap, just splitting a 2D lat/lng box instead of a 1D number line. A quadrant that comes back under 50 stops recursing; one still pinned at 50 splits again, up to a hard depth cap of 6 levels or a 500-query budget for the whole run, whichever comes first.

Two listings surfacing in overlapping quadrants get written once — deduplicated by listing_id before anything is billed. And one quadrant timing out or returning something unparseable never takes down the run: it's logged with its bounds and skipped, while its siblings keep going. A dense metro under-counting at the deepest tiling level is a documented, logged limitation — never a silent one.

What do you get back?

One row per listing: listing_id, title, property_type, bedroom_count, bathroom_count, monthly_rent_amount + currency, up to 5 photo_urls per listing (configurable), a derived listing_url, the resolved search_city, and an ISO-8601 scraped_at timestamp. Pass a city + state pair from the built-in ~100-metro table, or hand it exact lat/lng bounds for anything the table doesn't cover.

Is this target defended, and what happens if that changes?

Not today. Recon across roughly 30 requests, 6 metros, and two browser impersonations turned up no anti-bot wall anywhere on the GraphQL endpoint, so it ships on a pinned US datacenter proxy group rather than a residential one by default. That's a measured decision, not a permanent one: the retry-with-backoff on 408/429/503, the rotating Chrome/Firefox/Safari fingerprint on every request, and the fault isolation per quadrant are wired in regardless — and flipping the proxy group to residential is a one-field change in the input, no code and no republish, the day that stops being true.

Pricing

Pay-Per-Event: $0.20 per run (a flat warm-up charge, fired once) + $0.004 per unique listing row written to your dataset. 1,000 listings run about $4.20. No subscription, no minimum — Apify hands every new account $5 of free trial credit to try it.

FAQ

Do I need a FurnishedFinder account or API key?
No. The Actor calls FurnishedFinder's own public GraphQL endpoint directly — no login, cookie, or key.

What if my city isn't in the built-in metro list?
Skip City/State and pass the four lat/lng bounding-box fields directly — any US viewport works.

Why did my run come back with fewer rows than Max results?
FurnishedFinder caps a single query at 50 results; recursive quadrant tiling goes past that up to a depth and query-count safety cap. An extremely dense bounding box can still under-count at the deepest tiling level — logged, never silent.

Does the same listing ever get billed twice?
No. Rows are deduplicated by listing_id across every overlapping quadrant before anything is written or charged.


😈 Furnished Finder Corporate Housing Scraper tiles the map past FurnishedFinder's 50-result cap and hands back clean, deduplicated rows — price, bed/bath, photos — for any US metro or custom bounding box. $4.20 per 1,000 listings, pay only for results that land, no card required to try.

No captcha to fight on this one — just a schema that lies about its own naming convention. We still read it twice. 😈

Top comments (0)