Scrape ZipRecruiter.com job postings by keyword and/or location. Get normalized rows with title, company, location, remote flag, structured salary, posted date, job type, and apply URL, ready for CSV, JSON, or API export. No login, no API key required.
Fixed — the actor delivered garbage rows while reporting SUCCEEDED
Run 878bvyzeAjSPJXY0A (2026-08-30, build 0.1.1) SUCCEEDED with 3 rows that
were not jobs: a title "ZipRecruiterZipRecruiter" linking to /, a company's
generic /Jobs/ listing page mislabeled with the company name as the job
title, and the search page's own URL echoed back. The immediately-following
run WWpsMfqQfdOf7B7Dv SUCCEEDED with 0 rows. Root cause, confirmed via a
fresh local recon (Camoufox + RESIDENTIAL, 2026-08-31):
curl-cffi cannot clear ZipRecruiter's Cloudflare Managed Challenge —
ever, on this target. 0/10 across all 4 BROWSER_PROFILES, on
RESIDENTIAL, in a fresh recon — matching the 2026-08-06 cloud recon
(docs/specs/ziprecruiter-jobs-scraper/CLOUD-RECON-RESULT.md) on both
proxy tiers. This is a genuine JS-execution wall (Cloudflare's challenge
page requires a real browser to resolve a client-side JS proof-of-work),
not an HTTP-fingerprint or proxy-tier problem — the 0.1 fix's move to
RESIDENTIAL could never have worked on its own. 878bvyzeAjSPJXY0A's 3
"rows" came from html_fallback.py's generic CARD_SELECTORS (including
a bare article) latching onto page furniture in whatever non-challenge,
non-results 200 response occasionally slips through — JOB_URL_MARKER
(added 2026-08-30, commit 6125bb34) correctly turns that class of
response into a zero-postings page instead of a garbage one, but it never
fixed extraction against a REAL results page, because neither
jsonld.py nor html_fallback.py had ever seen one.
The parser's fixtures were synthetic and wrong.jsonld.py assumed
ZipRecruiter's ItemList nests each node under
itemListElement[].item (schema.org's documented shape). The real page's
ItemList is FLAT — {"@type": "ListItem", "name": ..., "url": ...},
no item wrapper, no JobPosting sub-object — so jsonld.py silently
extracted zero postings from every real page and fell through to
html_fallback.py every time.
The fix: switched the transport from curl-cffi to Camoufox (ADR-0002's
browser layer) — the only thing capable of clearing this specific wall — and
added src/parser/next_flight.py, which parses ZipRecruiter's REAL wire
format: once Camoufox clears the challenge, the server-rendered page embeds
every job as a JSON object inside a Next.js RSC self.__next_f.push(...)
flight payload (title, company, location, structured salary, employment
type, remote/in-person flag, posted date, and the confirmed-live canonical
/c/<Company>/Job/<slug>/-in-<City,ST>?jid=... URL — all in one place, no
text re-parsing needed). This is now the PRIMARY extraction path; jsonld.py
(fixed to also handle the real flat ListItem shape) and html_fallback.py
stay as fallbacks. Also fixed two URL-building bugs the same recon caught:
the search radius param is radius, not the invented radius_miles; and
pagination is a path segment (/jobs-search/<N>), not a page= query param.
src/browser.py (new) owns the Camoufox/Playwright primitives, including a
poll loop that waits out the Cloudflare challenge's 6-18s clear time (a
plain page.goto() returns before the challenge resolves).
Bumped minMemoryMbytes/defaultMemoryMbytes (256→2048) and switched the
Dockerfile base image to apify/actor-python-playwright:3.11 — Camoufox
needs the Playwright base image's system libraries and materially more
memory than the curl-cffi-only build.
0.1 — 2026-08-27
Fixed
Default proxy resolution never used RESIDENTIAL._build_proxy_factory()
passed groups=spec.get("apifyProxyGroups") or None — with no explicit
override (the QA fixture and every real customer input we've seen), None
resolves to Apify's automatic/datacenter pool. run_usage.py on both the
last SUCCEEDED (kLwRkRaHHl4IeWAWZ) and the immediately-following FAILED
(QmcKcOo9IjmrvLKXW) run confirms zero PROXY_RESIDENTIAL_TRANSFER_GBYTES
on either — neither run touched the residential tier, despite it being
entitled on this account since the 2026-08-20 STARTER upgrade. ZipRecruiter's
Cloudflare front clears far more reliably on RESIDENTIAL exits than on the
automatic pool, matching the escalation ladder that already worked for
idealista/amazon-reviews/bayut. Now defaults to
apifyProxyGroups:["RESIDENTIAL"]
+ apifyProxyCountry: "US" (ZipRecruiter is a US board;
a geo-random exit risks a silent wrong-country 200, not an honest error)
unless the caller explicitly overrides. input_schema.json's
proxyConfiguration default/prefill updated to match so the Store UI is
honest about what a customer gets by default.
Fixed a stale assertion in test_pay_per_event_matches_spec_exactly — the
2026-08-26 pricing commit (0463e80) live-priced the Actor at
actor-start $0.20 / result $0.0015 but left the test asserting the old
$0.03 / $0.0012 placeholder values, which is a local-red regression
unrelated to this fix but blocking a clean gate run.
Ruff UP017 autofix in tests/test_scraper.py (pre-existing, unrelated).
Known limitation
Not yet cloud-verified. The account's monthly usage hard cap was exceeded
($29.58 / $29, cycle 2026-08-19 → 2026-09-18) at investigation time — every
apify api run-start attempt returns
platform-feature-disabled: Monthly usage hard limit exceeded
, including on the already-pushed build. This
fix cannot be cloud-QA'd or pushed until the cap clears or is raised.
0.0.1 — 2026-08-06
Scaffolded skeleton (T01). Boots, reads input, pushes one
placeholder dataset row. Real curl-cffi/parser pipeline pending
(T02-T11 per docs/specs/ziprecruiter-jobs-scraper/tasks.md).