Quick answer: When Flippa's search returns metadata.totalResults: 0, the results array is not empty — it still contains about five unrelated "recommended" listings. Read len(results) and you will hand your customer five junk rows labelled as search hits. totalResults, not the array length, is the only trustworthy answer to "did we match anything?"
How does a zero-result search return five results?
Confirmed live on 2026-09-16 with two filters Flippa's search doesn't actually support — status=closed and status=sold. Both came back with metadata.totalResults: 0, and both came back with five rows in results, every one of them status: "open" and unrelated to the filter that was requested.
That's a recommendation carousel sharing a payload key with the search results. Perfectly sensible as a product; quietly poisonous as a data source. The rows are real listings, so nothing looks malformed. They just aren't answers to the question that was asked.
The handling has to be blunt about it:
parse_search_pagereturns([], 0)whenevertotalResults == 0, discarding whateverresultscontains.
Throwing away five real-looking rows feels wasteful right up until you picture the alternative: a customer filtering for sold businesses and receiving five open ones, with no way to tell.
Why can't you just regex the JSON out of the page?
Because it isn't JSON in a tag. Flippa's results are a bare JavaScript object literal assigned inside an inline <script>:
const STATE = {"results": [...], "metadata": {...}};
There is no <script type="application/json"> to grab, and there is no reliable closing marker to regex against. Searching forward for </script> works until the day a string value inside the payload contains that literal text — then your regex stops early, your JSON is truncated, and your parse fails on a page that plainly has results.
So the extractor walks the braces by hand, with string-awareness and escape-awareness, matching the closing } to the { that opened the object:
STATE_MARKER_RE = re.compile(r"const STATE\s*=\s*")
Find the marker, then count depth — skipping anything inside a quoted string, respecting backslash escapes. It's twenty lines instead of one, and it's the difference between a parser that works and one that works until it doesn't.
What happens when that extraction fails?
It fails loud, naming the module.
This is the deliberate opposite of the usual instinct. The tempting behaviour is to return an empty list and let the run finish green. But an Actor that returns zero rows and exits SUCCEEDED scores 100% on every health dashboard while delivering nothing — a silent under-delivery that nobody is paged for and the customer pays for.
If the page plainly has results and the extractor returns None, that is a markup change and it must surface as a failure, not as an empty dataset.
Where does per-item fault isolation actually live?
One layer up from the parser, and it's a different failure vocabulary on purpose.
The fetch layer rotates browser impersonation profiles (chrome131, firefox135, safari180) and cycles a fresh proxy session on each retryable failure, with exponential backoff on 408/429/503 and network errors. A page that never returns 200 — or a proxy that never gets built — degrades to None:
it never raises out of this module, so one bad category/page can never abort the whole run.
So a dead page is a recoverable per-fetch event, a broken STATE extraction is a loud incident, and a zero-totalResults response is an honest empty answer. Three different things that a naive try/except around the whole loop would flatten into one.
Why rotate three impersonation profiles instead of picking the best one?
Because "the best one" is target-specific and changes. We have measured the Chrome fingerprint being the block on three separate targets — one returned 503 to chrome131 while serving firefox133 a clean 200 on the identical request. The TLS/H2 fingerprint is part of what's being judged, not just the user-agent string.
Rotating on retry means a profile-specific block costs you one attempt rather than the run.
Are the categories guessed?
No — the 15 top-level filter[vertical] values were read verbatim off the "browse by category" links rendered on Flippa's own search page on 2026-09-16, not inferred from URL patterns. A guessed enum value is a filter that silently matches nothing, which brings you right back to the five-recommended-listings problem.
What a row looks like
{
"listing_url": "https://flippa.com/11234567",
"title": "Established SaaS — recurring revenue, 4 years old",
"price_text": "USD $2,273,879",
"currency_label": "USD $",
"status": "open",
"vertical": "saas"
}
Note price_text alongside the parsed number: the string is kept exactly as Flippa renders it, currency label and all. Formatted money is lossy to parse and trivial to keep, so we keep both.
😈 Flippa Business Listings Scraper pulls online-business and website listings from Flippa's marketplace across all 15 categories — title, canonical URL, asking price (parsed and as-displayed), currency, status, vertical and listing metadata — paginated and deduped. We handle the blocks, the retries, the fingerprint rotation and the recommendation rows that masquerade as search results. $6.20 per 1,000 results.
FAQ
Why does a Flippa search with zero matches still return rows?
Because the results array doubles as a recommendation carousel. When metadata.totalResults is 0, it still carries roughly five unrelated open listings. We discard them and report an honest zero.
Is the results payload JSON in a script tag?
No. It's a bare JS object literal assigned to const STATE, which is why extraction walks the braces with string- and escape-awareness rather than regexing for a closing marker.
What happens if Flippa changes its markup?
The Actor fails loudly naming the extraction module. It will not fall through to zero rows and a green run — a SUCCEEDED run that delivered nothing is worse than a failure, because nothing alerts on it.
Can I scrape a single category?
Yes. The 15 filter[vertical] values were taken verbatim from Flippa's own category links, so the filters match what the site actually accepts.
Does it need an account?
No. These are public search-results pages. No login, no API key.
Top comments (0)