Scrape French job offers from hellowork.com by keyword and city. One row per job: title, company, city and department, contract type, salary when disclosed, working hours, remote policy, posting date and apply method. We handle blocks and retries.
First working build: the server-rendered search page (/fr-fr/emploi/recherche.html?k&l&p), curl-cffi (chrome131),
selectolax, Pydantic input and rows. No warm-up request and no detail fetch.
Paging stops on maxItems, ceil(total / 30) pages, a page with zero cards, or a page with no unseen ids.
Rows are deduplicated by jobId across queries, cities and pages. "Unseen" for the stop rule is per search;
the run-wide set only gates emission.
Salary is parsed from the card label: ranges, single values, per-day and per-month rates, decimal amounts.
€ maps to EUR; undisclosed salary is null.
postedAt is derived from "il y a N ..." text from the run time; "plus de 1 mois" counts as 1 month back.
contractTag mixes remote policy and contract duration ("2 ans"); only text containing "travail" is emitted as
remotePolicy. Durations have no output field.
A search that fails after retries is isolated; the run exits non-zero only when every search failed and no row
was written. A parseable page with zero offers succeeds with zero rows.
When a proxy is configured the exit country is pinned to FR and a 403/429 rotates the session id.
Pricing: actor-start $0.20, result $0.002 ($2.00 per 1,000 jobs).
Fixture provenance
Captured 2026-10-08 (local, direct): p1.html (k=python&l=Paris, page 1), p2.html (page 2),
c1.html (k=comptable&l=Lyon), empty.html (k=zzqxkjwv&l=Paris). Full pages (~620 KB each) are not committed.
tools/trim_fixtures.py (offline, no network) keeps the original <title>, the original
button[data-cy=offerNumberButton], and a selected subset of real cards (26 per page for p1, p2 and c1; none for
empty). From each kept card it removes only the hide-offer form and the bookmark turbo-frame, which the parser
never reads. Totals stay real: 225, 225, 1565 and 0.
Cards were chosen to cover the traps: empty company (anonymous), no salary, single-value salary, per-day and
per-month salary, decimal salary, super recruiter, remote tags, duration tags, approximate matches,
Île-de-France without a department, "plus de 1 mois".
DERIVED (hand-built, not captures): hw_challenge.DERIVED.html (no cards, no total button) and
hw_sponsored.DERIVED.html (one real card with product_list changed to a sponsored value). Cloud recon should
confirm the real blocked body and whether a sponsored card exists.
make_page() in tests/conftest.py tiles real trimmed cards into 30-card pages with rewritten ids for paging
tests. It is a test helper only.