Scrape ATS jobs from company career pages: enter company names or careers URLs and get every open job, live from each company's Greenhouse, Lever, Ashby, Recruitee, Personio or Teamtailor board, in one format. Filter by keyword, place, department, job type, salary or date, or get only new jobs.
Versions follow MAJOR.MINOR.PATCH (src/version.py); Apify shows MAJOR.MINOR from .actor/actor.json.
Every run logs its version and records it in the RUN_STATS key-value record.
About future failures: these scrapers read each ATS's public job feed. ATS vendors change those feeds
without notice. If a company that used to work starts failing, or RUN_STATS shows skippedRecords,
suspect a site/format change first, not a regression in this code. Check the run log: it names the
company and says what didn't parse. One broken site never affects the other companies in a run.
1.3.5 (2026-10-16)
A run that returns nothing (every input bad, private, blocked or unreachable, or an empty input) now ends
SUCCEEDED with an empty dataset and the reason as its status message and in the log, instead of FAILED. Only an
unexpected error still fails the run. RUN_STATS keeps the reason as before.
No change to output fields or price.
1.3.4 (2026-10-03)
A run no longer fails when the store it remembers earlier runs in can't be opened (403: the account already has
a store of that name that this actor didn't create). It falls back to humble-echidna-<name>, and if that is
refused too it runs without memory for that run. Users' existing memory is untouched: the usual name is tried first.
1.3.3 (2026-10-01)
Waits out longer Retry-After answers: when a site or API asks it to wait before retrying, it now waits up
to 90 seconds (was 30), once per site, with at most 3 minutes of such long waits per run; a longer wait, or a
site that asks again after it was waited out, is reported with the reason instead of stalling the run.
A near-empty page that only shows Cloudflare Turnstile's check is reported as a bot check (blocked), like the
other challenge pages.
No change to input, output or price.
1.3.2 (2026-09-28)
2 ready-to-run examples published on Apify Store and linked from the README.
1.3.1 (2026-09-28)
Cheaper on paid Apify plans: $1.35 per 1,000 jobs on Starter, $1.20 on Scale and $1.05 on Business ($1.50 on the Free plan).
1.3.0 (2026-09-27)
Companies typed as a website or careers page are found far more often. Besides links on the page, the actor
now reads boards embedded in it (Greenhouse embed scripts, Lever and Ashby job-API addresses, Ashby iframes),
career sites Teamtailor and Recruitee host on the company's own domain, the careers/jobs pages the site links to
(one hop; a homepage goes home -> careers -> jobs), and same-site scripts named after careers or jobs. When the
site renders its jobs with JavaScript or blocks bots, a board named like the website is used only if the board
links back to that same website (Ashby's published website, job links on the company's domain, the Greenhouse
board's logo link or its redirect to the company's careers page, the Lever board's logo link). Boards that
publish no website (Personio) are never taken from a domain. Bot checks are reported, never worked around.
Name lookups download a fraction of what they did: cheap existence checks instead of whole boards (Lever
limit=1 plus the first 32 KB of its board page, Ashby's board page instead of its full feed, Greenhouse without
descriptions). OpenAI's lookup went from 15 MB to 0.6 MB, Palantir's from 7.4 MB (4.1 s) to 0.3 MB (0.8 s). All
spellings are checked at once; the first spelling with a match wins without waiting for the rest.
Name matching is stricter and wider at once: new spellings (descriptors dropped: "Scale AI" -> scale, legal forms
such as SE & Co. KG, accents: "1KOMMA5°"), and a board's company name must equal the typed name or start with it
at a word boundary ("Rover.com" no longer matches "Ro"). Same-named boards that link to different websites are
flagged in the log.
Fixed: a careers page linking Greenhouse's own marketing site (www.greenhouse.io/plans ) was read as a board;
Lever company names with entities ("Dun & Bradstreet") are unescaped; the company name on Lever boards is
read from the first 32 KB of the page instead of the whole ~1 MB page.
Benchmark: benchmark/ (88 companies with hand-verified boards plus 7 that hire elsewhere, each typed as name,
homepage and careers page). Resolved: 52% -> 88% overall (names 90% -> 92%, homepages 34% -> 83%, careers pages
32% -> 88%); on the 20 held-out companies never used for tuning 56% -> 95%. Wrong matches: 2 -> 2 (both names
shared by two companies: Clay, Lunar); all 21 negative inputs still rejected. Slowest input 91 s -> 21 s.
1.2.2 (2026-09-26)
README: real screenshots of the input form and the Output table, from our own runs.
1.2.1 (2026-09-26)
Input descriptions written for AI agents (Apify's MCP server shows values, not option titles, and no
limits): option values, ranges and defaults are now in the text.
1.2.0 (2026-09-25)
Big boards are read again: a board's whole-job feed may now be up to 32 MB (was 10 MB, which turned away e.g.
OpenAI's Ashby board at 14.5 MB).
Three new filters, applied before you're charged, so a job they leave out is never charged:
Departments (departments): jobs whose department or team contains any of the words, case-insensitive.
Employment types (employmentTypes): any of FULL_TIME, PART_TIME, CONTRACT, TEMPORARY,
INTERNSHIP, VOLUNTEER. Greenhouse and Teamtailor state no employment type, so none of their jobs pass it.
Minimum salary (minSalary, with minSalaryCurrency, default USD, and minSalaryPeriod, default YEAR):
jobs whose pay range reaches the amount at its top, from the ATS's own salary field or else from pay text in the
description. Hourly, daily, weekly and monthly pay is compared as a full-time year (2,080 hours, 260 days, 52
weeks, 12 months); currencies are never converted. Jobs with no comparable pay (none published, another
currency, or no period) are kept by default; turn off Keep jobs with no comparable salary
(keepJobsWithoutSalary) to drop them. With a minimum salary, descriptions are read to find pay even when
Include full job description is off; they're still left out of the results.
Each company's log line and RUN_STATS.boards[...].notes say how many jobs a filter could only leave out or keep
for lack of data (no department, no stated employment type, no comparable salary).
"Only jobs new since my last run" is remembered per search, new filters included: changing a department,
employment type or any salary option starts a fresh search with its own memory, so no job is hidden by another
search's memory. A saved task or schedule that doesn't use the new filters keeps its memory (the new fields'
defaults aren't part of a search until a minimum salary is set), so it doesn't return and charge every job again.
Job titles, departments, teams, locations, countries and company names are trimmed on every platform: leading,
trailing and repeated whitespace is removed (Ashby served some of Ramp's titles with a leading space). With
Also report closed and changed jobs, a job remembered with the old spacing isn't reported as changed.
1.1.0 (2026-09-25)
Closed and changed jobs. A new option, Also report closed and changed jobs (includeClosedAndChanged, off
by default), turns a scheduled run into a job-change feed. Besides new jobs, each run returns, once, every job it
returned before that has since left the company's board (changeType: "closed", with its last known title,
locations, department, employment type, salary text and link, and closedDetectedAt) or whose title, locations,
department, employment type or salary changed (changeType: "changed", with changedFields old and new). New jobs
are marked changeType: "new", and every row has firstSeenAt. These rows are charged like any job; with the
option off, output and charges are exactly as before. The option implies Only jobs new since my last run.
No false "closed". A board that fails, is blocked, times out or has records that don't parse gets no closed-job
check on that run. Nor does one that suddenly lists nothing, loses more than half of its tracked jobs at once, or
halves its open jobs: that drop is only reported if the next run sees it too. Each skipped check is in the log and
in RUN_STATS.closureChecksHeld, and run counts by type are in RUN_STATS.changes.
The "only new jobs" memory keeps, with this option on, a few fields per job in the same record (one per company and
search). A memory from an earlier version is read as a baseline: its jobs' fields are recorded on the first run with
the option, and nothing is reported for jobs that closed or changed before then. A quiet run writes nothing.
Dataset: new nullable fields changeType, changedFields, closedDetectedAt, firstSeenAt, and a "Job changes"
view.
1.0.3 (2026-09-25)
No new platforms: 18 more were checked and none passed. BambooHR, Jobvite, iCIMS, JazzHR, Pinpoint, Taleo,
SuccessFactors, Comeet, Freshteam, Homerun, JOIN, Dover, Gem, Polymer, Manatal, Zoho Recruit, Trakstar Hire and
JobScore were each checked for a documented public job feed, their terms of service and their robots.txt. Every one
failed at least one of these: the feed needs a key or token, is a private per-company link, or is documented only for
the company's own careers page, or the terms forbid automated access or reuse (the README FAQ quotes each reason).
Their URLs, and short forms such as bamboohr:acme, are now recognised and reported in RUN_STATS.unresolved with
the reason. A careers page that links only to one of them says why instead of "no supported job board". Nothing is
requested from these platforms, and they're never guessed from a company name.
README: says that jobs are fetched live during the run (no pre-crawled index), so every job returned is posted at
that moment.
1.0.2 (2026-09-24)
Workday, Workable, Rippling and Breezy HR are no longer read (10 → 6 platforms: Greenhouse, Lever, Ashby,
Recruitee, Personio, Teamtailor). A terms-of-service review of every platform kept only those that publish their job
feed for machines and whose terms allow this use. Workday career sites are only readable through the internal
backend of Workday's own career-site app, not a documented public API, and Workday's terms require a separate
agreement for its APIs. Workable's widget endpoint is documented only for embedding a company's jobs on its own
website. Breezy HR's terms forbid access other than through its publicly supported interfaces and name scraping.
Rippling's board endpoint isn't a documented public API and the review didn't clear it for automated third-party
use. Their URLs (and workday:/workable:/rippling:/breezy: short forms) are still recognised and reported
in RUN_STATS.unresolved with the reason, like SmartRecruiters since 1.0.1; company names are no longer looked up
there. The rest of a run is unaffected.
Default input: 6 small real boards, one per platform (Linear, bunq, Greenhouse, Outreach, ottonova, Teamtailor),
instead of examples that included a trial account and a 1-job board.
Teamtailor: career sites with more than 100 open jobs return every job. The feed lists 100 at a time and only the
first 100 were read; the rest are now read page by page (Teamtailor's offset parameter).
Personio: jobs written only in another language keep their description. Personio's English feed leaves that text
out, so it's read from the company's own-language feed (one extra request, only for companies that need it).
"Full or part time" jobs now have no employment type (null) instead of PART_TIME.
Recruitee: salary from Recruitee's own fields comes back as numbers (Recruitee sends strings), and a range left at
0 is no salary instead of 0-0 with a currency.
1.0.1 (2026-09-24)
Shared safety code (mms_common, the same in every actor of this portfolio): robots.txt per RFC 9309 (byte-order
marks, product-token matching, percent-encoding, a 500 KiB cap counted while streaming), a Crawl-delay above 30 s
reports that site instead of stalling the run, Retry-After honoured (seconds or a date; above 30 s reported),
the private-network guard on every request and redirect hop (resolve-and-check, then connect to the checked
address; ports 80 and 443 only; IPv6 forms that wrap an address, 6to4 and site-local addresses refused), a time
limit per attempt and one for the whole request (redirects, retries and robots.txt included), and decompression in
small steps with a capped size. robots.txt rules are matched in linear time, so a hostile file can't stall a run;
a robots.txt cut at the size cap loses its partial last line, and its retries honour Retry-After. A malformed
address or redirect target is reported for that company instead of crashing the run.
Company names: a guess that fails for robots.txt or network reasons (e.g. an unknown Personio name redirecting to
Personio's site, whose robots.txt answered 429) now just means "not on that platform", with no retries and no
misleading robots.txt message; an unknown name is answered in seconds.
A careers domain that doesn't exist is reported as such (it was reported as a robots.txt problem).
Jobs reserved against the run's limit for a company whose job details robots.txt then disallows are given back to
the other companies in the run.
Honest identity and robots.txt: requests now carry an identifying User-Agent
(HumbleEchidnaApify/1.0 (+https://apify.com/humble-echidna)) instead of a desktop-browser one, and every host is
checked against its robots.txt first (job APIs, board pages and careers pages alike). A page a site disallows is
never requested: that company is reported as failed with the reason. A site's Crawl-delay is respected (Lever asks
for 1 second between requests, so large Lever runs are a little slower).
SmartRecruiters is no longer read: its robots.txt disallows its job API for every automated client except
LinkedIn's. SmartRecruiters URLs are recognised and reported with that reason; names are no longer looked up there.
10 platforms remain. The default input drops its SmartRecruiters example.
The ATS code now lives in a shared package (common/mms_ats) that the single-platform
actors (Workday, Greenhouse, Lever, Ashby Jobs Scraper) also use, so a fix to one platform reaches all of them.
An empty companies list now also writes the RUN_STATS record before failing the run.
The proxy input is removed. Every platform read here is a public API that needs no proxy, and a proxied
connection can't be checked against private networks. Saved inputs and tasks that still contain proxy keep
working: the field is ignored, with a note in the log.
A run where no company can be read now ends successfully with 0 jobs, with a status message saying so, instead
of failing: for example when every company is on SmartRecruiters (not read, see above) or no name could be found.
RUN_STATS (unresolved) and the log say why for each company.
"Only jobs new since my last run": a job is remembered as returned only once it's in your dataset. A run that
stopped after collecting jobs but before writing them, or that cut them at the max cost per run, used to mark them
as seen, so later runs never returned them.
One malformed company line (e.g. http://[bad) is reported as unresolved with the reason and the rest of the run
continues; it used to crash the whole run.
RUN_STATS has the same keys on every exit path (dropped_invalid, failure and error included), so a run
that stops early is still readable.
Once every job the run can pay for is reserved, companies still being fetched are stopped right away instead of
finishing for nothing; if a company holding reserved jobs then fails, the stopped ones get another turn.
A job record with a missing required field no longer uses up a place under max jobs / max cost per run.
Personio and Teamtailor feeds are parsed with DTDs forbidden (no entity expansion, no external entities).
Workday: when a title keyword itself matches more than Workday's 2,000-job list limit, the log says so.
"Max jobs per run" input, and the maximum cost per run is honoured: once either is covered, companies
still being fetched are cancelled and the rest are skipped (listed as boardsSkipped in RUN_STATS).
Workday: an empty first page is retried once, and a missing job total no longer stops paging (a busy board
could otherwise return 0 jobs with no error).
Companies can be given as a name (looked up on every platform except Workday; only boards whose own
company name agrees are used), a careers page (scanned for a link to its job board), or a job-board URL.
What each resolved to, and what couldn't be found and why, is in the log and RUN_STATS.
Board slugs are normalized to lowercase (fixes capitalized Rippling URLs, which Rippling rejects, and stops
"Linear"/"linear" being fetched twice).
Charged per job returned, through Apify's standard apify-default-dataset-item event (no custom event, so
nothing is billed twice).
One output schema for all of them; employment type (FULL_TIME, PART_TIME, CONTRACT, TEMPORARY,
INTERNSHIP, VOLUNTEER) and workplace type (remote, hybrid, onsite) normalized across platforms.
Salary: structured where the ATS provides it, otherwise extracted from pay-transparency text in the
description (salarySource says which).
Company display name filled in for Lever, Ashby and Rippling (their job APIs omit it).
Filters: title keywords (pushed down to Workday's server-side search), exclude keywords, locations, remote,
posted within N days, max per company; "only jobs new since my last run" (remembered per company and per
filter combination in the user's own ats-jobs-seen store; keys are restricted to Apify's allowed characters).
Failure isolation: a failing company, a malformed job record, or a failed detail request only affects itself.
Performance: parallel pagination (SmartRecruiters 4,800 jobs: 23s → ~3s) and descriptions fetched only for jobs
the run returns.