Quick answer
A scraper run can report SUCCEEDED with rows in the dataset and still have delivered nothing real. Our ZipRecruiter Jobs Scraper hit exactly that: a green run wrote 3 rows, and every one of them was page furniture — a nav link, a company's generic listings page, and the search page's own URL echoed back as a "job." itemCount > 0 proved a dataset write happened. It proved nothing about whether the write was a job posting. The fix wasn't a better bot-block detector; it was a target-specific shape check — every posting's URL must contain /Job/, ZipRecruiter's own confirmed-live posting path. The Actor now runs on Camoufox (curl-cffi cannot clear ZipRecruiter's Cloudflare Managed Challenge at all) and returns typed rows — title, company, location, structured salary, posted date, apply URL — for $0.20 per run plus $0.0015 per result, $1.70 for 1,000 postings.
The run that passed our own gate 🕳️
Here's the uncomfortable part: this run looked exactly like the evidence our publish gate is built to accept. SUCCEEDED. 3 rows. RESIDENTIAL proxy confirmed by billing. By every check we had, that's a working scraper.
Open the dataset and the three rows were:
- A posting titled
ZipRecruiterZipRecruiter, linking to/. - A company's generic
/Jobs/listings page, with the company name copied into thetitlefield as if it were a job title. - The search results page's own URL, echoed back as its own "posting."
None of these are jobs. All three passed our only validation at the time: "has a title and a link." Page furniture clears that bar trivially — nav links have titles, widgets have links.
What actually happened: after roughly three blocked attempts, one request came back 200 — not a Cloudflare challenge page (so our is_blocked_page() check didn't fire), and not real search-results markup either. It was some other page state in between. Our fallback parser's card selectors are deliberately generic — they have to handle markup drift across ZipRecruiter's own page variants — and one of them was a bare article tag. A bare article selector will happily latch onto a nav widget on a page that has no actual job cards on it.
The fix was not a smarter block detector. It was accepting that ZipRecruiter's real, confirmed-live posting URLs all follow one shape — /c/<Company>/Job/<Title-slug>/-in-<City,ST> — and rejecting any extracted posting whose source_url doesn't contain /Job/. That single check turns a fabricated-rows run into an honest zero-postings run. The general lesson is bigger than this one Actor: a non-zero row count is necessary evidence of delivery, never sufficient. A blocked or half-blocked page can manufacture rows just as easily as a working one can.
The other bug: a total failure that looked like an honest empty search
The very next run after that one wrote 0 rows — and also reported SUCCEEDED. On the surface that's the correct behavior: a search that runs and matches nothing should succeed, not fail. Except this run didn't run a search. It hit 2 blocks and 3 network errors, exhausted every retry, and never once reached a real ZipRecruiter results page. Because none of those failures were the specific block signature our code checked for, the run fell through to the same "no postings found" success path an honest empty search takes — and PPE charged the run-start fee with nobody able to tell the difference from the outside.
We now track this explicitly: a page that never fetched successfully (network failure, or a non-retryable status exhausted after 5 attempts) sets a page_fetch_failed flag, and the run fails loud whenever zero rows coincide with that flag. Zero rows plus "we actually searched" succeeds. Zero rows plus "we never got there" now fails — the way it should have from the start.
What we handle for you 🛡️
- We run this one on Camoufox, not curl-cffi — ZipRecruiter's Cloudflare Managed Challenge is a genuine JS-execution wall (0 clears across all 4 browser-impersonation profiles, confirmed on RESIDENTIAL). Camoufox warms up and polls out the challenge before we touch the page.
- We rotate residential proxies through Apify Proxy on every block — fresh session, fresh exit IP.
-
We retry with exponential backoff on
408 / 429 / 5xx, up to 5 attempts per page, honoringRetry-After. - We validate the shape of what we scraped, not just that we scraped something. Every posting's URL has to match ZipRecruiter's own live posting path before it reaches your dataset.
- We fail loud when we never reached the target — a run that never completed a real search doesn't get to look like one that did.
- We keep the dataset clean — Pydantic-validated rows, ISO-8601 timestamps, stable IDs.
- You pay only for results that land. No data, no charge, beyond the small run-start fee.
Full output schema 📦
| Field | Type | Notes |
|---|---|---|
job_id |
string | Canonical source ID if present, else a deterministic hash of source_url
|
source_url |
string | Canonical posting URL — must contain /Job/ or the row is dropped |
title |
string | Posting title |
company |
string | null | Employer name |
location |
string | null | Display location text |
is_remote |
boolean | null | Remote-work flag |
salary_min / salary_max
|
number | null | Parsed from salary text |
salary_currency |
string \ | null |
salary_period |
string \ | null |
posted_at |
string | null | ISO-8601 normalized posting date |
apply_url |
string | Source-provided apply link, copied verbatim |
job_type |
string | null | Normalized employment type |
scraped_at |
string | ISO-8601 wall-clock scrape time |
Example row, from the real cloud QA run:
{
"job_id": "e3b0c44298fc1c14",
"source_url": "https://www.ziprecruiter.com/c/Acme-Co/Job/Senior-Backend-Engineer/-in-Austin,TX",
"title": "Senior Backend Engineer",
"company": "Acme Co",
"location": "Austin, TX",
"is_remote": false,
"salary_min": 130000.0,
"salary_max": 165000.0,
"salary_currency": "USD",
"salary_period": "year",
"posted_at": "2026-08-04T00:00:00Z",
"apply_url": "https://www.ziprecruiter.com/c/Acme-Co/Job/Senior-Backend-Engineer/-in-Austin,TX",
"job_type": "full_time",
"scraped_at": "2026-08-06T12:00:00Z"
}
Who this is for
Recruiting pipelines — pull fresh postings matching a role profile into an ATS or sourcing sheet.
Job-aggregator feeds — normalize ZipRecruiter listings into a consistent schema alongside other boards.
Market-rate research — aggregate structured salary data by title and location to benchmark comp bands.
Remote-role monitoring — track newly posted remote roles across a keyword or location on a schedule.
Frequently asked questions
Why did an early run report success with fabricated rows?
A request that returned a 200 neither our block detector nor our results parser recognized as what it actually was let a deliberately generic fallback selector (a bare article tag) latch onto nav links and page widgets. They passed our only check at the time — "has a title and a link" — which page furniture clears trivially. The fix requires every extracted posting's URL to contain /Job/, ZipRecruiter's confirmed-live posting path.
Why did a run report "no postings found" when it never reached ZipRecruiter at all?
It hit blocks and network errors on every attempt and exhausted every retry, but none of those failures matched our specific block signature, so it fell through to the same success path an honest empty search takes. We now flag any page that never fetched successfully and fail loud when that coincides with zero rows.
Does this run on curl-cffi like your other Actors?
No — this is a Camoufox Actor. ZipRecruiter's Cloudflare Managed Challenge is a genuine JS-execution wall; curl-cffi's browser-TLS impersonation cleared it 0 times across 4 profiles in testing. Camoufox actually executes the challenge's client-side proof-of-work.
What does 1,000 postings cost?
$1.70 — 1,000 × $0.0015 per result, plus the $0.20 run-start charge.
Try it
Live on the Apify Store: ZipRecruiter Jobs Scraper.
Search by job title, keyword, and/or location, filter by radius, remote-only, job type, and days-posted, and get back rows that are provably real job postings — not whatever a half-blocked page happened to render. Pay-per-event, no subscription, no card required to try.
Built by Devil Scrapes — we publish the traps we hit, including the ones a green checkmark almost let through.
Top comments (0)