--- name: ats-multi-board-jobs-scraper description: Read public company job boards as one normalised schema via the Apify Actor arman-bd/ats-multi-board-jobs-scraper. Detects which applicant tracking system sits behind a careers URL (Greenhouse, Lever, Ashby, Workable, Recruitee, Teamtailor or Personio) and returns one record per job with the same twelve fields regardless of vendor: title, department, team, location, remote flag, employment type, posted date, apply URL and the full description as plain text. Use when a mixed list of companies has to become a single jobs table, for hiring-signal tracking, job-board ingestion or recruiting analytics. Not for aggregator sites, candidate or applicant data, salary data, or boards that require a login. --- # Multi-ATS Jobs Scraper Apify Actor `arman-bd/ats-multi-board-jobs-scraper`. Give it careers URLs or bare board slugs, get one dataset record per job posting. It works out which applicant tracking system each source uses and flattens all seven into one schema. It runs without credentials; you supply nothing but the list. ## When to use it - A mixed list of companies has to become a single jobs table, and you do not want a branch per ATS vendor in your pipeline. - You are building or refreshing a job board and want new postings keyed on `sourcePlatform` + `jobId`. - Hiring-signal tracking: which departments a company is growing, how many open roles, how fast postings appear. - You have a careers URL but do not know, or care, which vendor is behind it. - Recruiting analytics across one job family (`searchTerms: ["engineer"]`) over dozens of companies at once. ## When not to use it - Job aggregators and search engines. This reads a company's own board, one company per source entry. - Candidate, applicant or recruiter data. Only published postings are returned. - Salary or compensation fields. None of these boards publish them in the public feed, so there is no salary field. - Boards that require a login, and any ATS outside the seven listed. An unrecognised source lands in `RUN_SUMMARY.failures`. ## Call it ```js import { ApifyClient } from 'apify-client'; const client = new ApifyClient({ token: process.env.APIFY_TOKEN }); const run = await client.actor('arman-bd/ats-multi-board-jobs-scraper').call({ careerUrls: [ 'https://job-boards.greenhouse.io/stripe', 'https://jobs.lever.co/palantir', 'https://ramp.com/careers', ], searchTerms: ['engineer'], maxJobsPerCompany: 50, normalizeSchema: true, }); const { items } = await client.dataset(run.defaultDatasetId).listItems(); const { value: summary } = await client .keyValueStore(run.defaultKeyValueStoreId) .getRecord('RUN_SUMMARY'); ``` One-shot over HTTP, when you want the rows back in the same request: ```bash curl -X POST "https://api.apify.com/v2/acts/arman-bd~ats-multi-board-jobs-scraper/run-sync-get-dataset-items?token=$APIFY_TOKEN" \ -H "Content-Type: application/json" \ -d '{"careerUrls":["https://job-boards.greenhouse.io/stripe"],"searchTerms":["engineer"],"maxJobsPerCompany":25}' ``` The Actor is also exposed through Apify's MCP server as `arman-bd/ats-multi-board-jobs-scraper`, so an MCP-capable agent can call it with no extra wiring. ## Input | Field | Type | Required | Default | Notes | |---|---|---|---|---| | `careerUrls` | string[] | yes | | Any careers page URL (`https://jobs.lever.co/palantir`, `https://channable.recruitee.com`), a company's own careers domain (`https://ramp.com/careers`), or a bare board slug (`stripe`). Mixed forms in one list is the point. Trimmed and deduplicated before anything is fetched. | | `platforms` | string[] | no | `[]` (all seven) | Restrict detection to `greenhouse`, `lever`, `ashby`, `workable`, `recruitee`, `teamtailor`, `personio`. An unrecognised value aborts the run with the list of valid ones. | | `searchTerms` | string[] | no | `[]` (all jobs) | Keep only postings whose `title` contains at least one term, case-insensitive substring match. Matches the title only, never the description. | | `maxJobsPerCompany` | integer | no | `0` (no limit) | Cap on saved jobs per source, applied after `searchTerms`. Paging stops as soon as the cap is met. | | `normalizeSchema` | boolean | no | `true` | `true` gives the unified twelve fields. `false` gives `sourcePlatform`, `company`, `jobId`, `raw` and `scrapedAt`, where `raw` is the vendor's own object untouched. | **`platforms` is the setting that decides how a run behaves, not how much it returns.** A source the Actor cannot identify from its URL or its page is resolved by trying each enabled platform in turn until one returns jobs, so a bare slug with `platforms` unset can cost up to seven attempts, while `platforms: ["greenhouse", "lever"]` caps it at two. Set it whenever you already know the shape of your list. Pass real board URLs where you have them and detection costs nothing at all. ## Output One record per job posting that passed the filters. | Field | Type | Notes | |---|---|---| | `sourcePlatform` | string | `greenhouse`, `lever`, `ashby`, `workable`, `recruitee`, `teamtailor` or `personio`. | | `company` | string | Company name as the platform reports it, falling back to the board slug. | | `jobId` | string | The platform's own posting ID, always stringified. Unique within a platform, so key on `sourcePlatform` + `jobId`. | | `title` | string | Job title, trimmed. | | `department` | string \| null | First department the platform publishes. | | `team` | string \| null | Second department or sub-team, where the platform has one. | | `location` | string \| null | Assembled from whichever location fragments the platform fills in, joined with commas, duplicates dropped. | | `isRemote` | boolean | The platform's own remote flag where it has one, otherwise inferred from keywords in the title and location. | | `employmentType` | string \| null | The platform's own vocabulary, not normalised. `FullTime` on Ashby, `Full-time` on Lever, `fulltime_permanent` on Recruitee. | | `postedAt` | string \| null | ISO 8601 where the platform publishes a timestamp. `null` when it publishes nothing parseable. | | `applyUrl` | string \| null | Direct application link. | | `descriptionPlain` | string | Full description as plain text: HTML stripped, entities decoded, list items prefixed, Lever's separate description blocks re-joined. Never `null`, empty string when the feed carries no body. | | `raw` | object | Only when `normalizeSchema` is `false`. The vendor's response object for that posting, exactly as it arrived. | | `scrapedAt` | string | Run timestamp, ISO 8601. | A real record, long strings trimmed: ```json { "sourcePlatform": "greenhouse", "company": "Stripe", "jobId": "8023928", "title": "Account Executive, Bridge", "department": "8589 Bridge - S&M", "team": null, "location": "London", "isRemote": false, "employmentType": null, "postedAt": "2026-07-30T06:59:38-04:00", "applyUrl": "https://stripe.com/jobs/search?gh_jid=8023928", "descriptionPlain": "Who we are\n\nAbout Stripe\n\nStripe is a financial infrastructure platform…", "scrapedAt": "2026-08-06T12:00:00.000Z" } ``` ## RUN_SUMMARY Written to the run's key-value store under the key `RUN_SUMMARY`. **Read it.** It is the only place that says which platform each source resolved to and how confident that was. ```json { "sourcesRequested": 10, "sourcesFailed": 1, "failures": [ { "source": "https://example.com/careers", "platform": null, "error": "no ATS matched \"example\" …" } ], "jobsSaved": 36, "byPlatform": { "greenhouse": 8, "lever": 4, "ashby": 8, "workable": 4, "recruitee": 4, "teamtailor": 4, "personio": 4 }, "detected": { "https://ramp.com/careers": { "platform": "ashby", "company": "ramp", "via": "page-signature" }, "https://vanta.com/careers": { "platform": "greenhouse", "company": "Vanta", "via": "probe" } }, "filters": { "platforms": [], "searchTerms": [], "maxJobsPerCompany": 4, "normalizeSchema": true }, "finishedAt": "2026-08-06T11:36:33.848Z" } ``` `sourcesFailed` above zero is normal on a mixed list and every reason is spelled out in `failures`. `detected` is keyed on exactly the string you passed in, so join it back to your input list. Its `via` is `url-pattern` (read straight off the URL, certain), `page-signature` (an ATS link found on the company's own careers page, near certain) or `probe` (guessed by trying platforms in order, treat as a hypothesis). A source absent from both `detected` and `failures` cannot happen; one of the two always names it. ## Behaviour to plan around - **A `probe` match is a guess.** For a bare slug the first enabled platform that returns any jobs wins, in the order Greenhouse, Lever, Ashby, Workable, Recruitee, Teamtailor, Personio. Two vendors can both answer for the same slug. Check `detected[...].via` before trusting `company` on a probed source, or pass `platforms` to make it deterministic. - **One bad source never aborts the run.** It is recorded in `failures` with the reason and the run continues. The Actor only throws when every source failed. - **A URL that belongs to an excluded platform fails loudly.** It is not silently probed; `failures[].error` says which platform it looks like and that `platforms` excluded it. - **Field coverage varies by vendor and that is not a bug.** Greenhouse publishes no employment type, Teamtailor publishes no department. Missing values are `null` and are never guessed. `isRemote` is the one exception: it falls back to a keyword read of title and location. - **`company` on Lever is the board slug**, lower case, because Lever's public postings do not carry a display name. Do not use it as a label without mapping it yourself. - **Some Personio tenants do not serve their feed to this Actor** and are recorded as failures; others return normally. Nothing here works around that, so plan for partial coverage on Personio-heavy lists. - **Transient errors are retried** three times with linear backoff. A 404 and a malformed payload fail immediately for that source. - **`maxJobsPerCompany` counts kept jobs, not listed jobs.** With `searchTerms` set, a board of 400 postings and a cap of 25 saves the first 25 matches, and `RUN_SUMMARY.jobsSaved` reflects the cap, not the board size. - **`postedAt` timezones are whatever the vendor sends.** Greenhouse offsets, Workable a date only. Normalise to UTC yourself before comparing across platforms. ## Recipes **One table across a mixed company list.** No per-vendor integration, no branching. ```json { "careerUrls": [ "https://job-boards.greenhouse.io/stripe", "https://jobs.lever.co/palantir", "https://jobs.ashbyhq.com/ramp", "https://channable.recruitee.com", "https://polestar.teamtailor.com" ] } ``` Group the dataset by `sourcePlatform` and `company`; check `RUN_SUMMARY.byPlatform` matches your expectation before reporting counts. **Scheduled new-postings feed.** Run daily, diff against the previous run. ```json { "careerUrls": ["https://ramp.com/careers", "https://vanta.com/careers", "figma", "stripe"], "normalizeSchema": true } ``` Key on `sourcePlatform` + `jobId`; anything new is a new opening. `postedAt` is a weaker signal because several vendors backdate or omit it. **One job family, many companies, bounded cost.** Deterministic detection, capped rows. ```json { "careerUrls": ["https://job-boards.greenhouse.io/stripe", "https://jobs.lever.co/palantir"], "platforms": ["greenhouse", "lever"], "searchTerms": ["engineer", "data", "machine learning"], "maxJobsPerCompany": 100 } ``` Count matches per `company` and `department` for a hiring-intensity view. **Vendor-native fields.** When the unified schema drops something you need. ```json { "careerUrls": ["https://jobs.ashbyhq.com/ramp"], "normalizeSchema": false } ``` Each record is `sourcePlatform`, `company`, `jobId`, `raw` and `scrapedAt`. Read the vendor's own keys from `raw`; its shape differs per platform, so branch on `sourcePlatform`.