--- name: workday-jobs-scraper description: Read open requisitions from Workday-powered careers sites as structured records via the Apify Actor arman-bd/workday-jobs-scraper. Returns one record per posting with the tenant and site IDs, a stable posting slug, title, location text, the company's requisition number, the relative posted label, a public apply link and, on request, the exact posted date, time type, remote type and the full description as plain text. Use for enterprise hiring intelligence at scale, requisition diffing to spot backfills and cuts, executive-search target lists or ingesting many corporate careers sites on a schedule. Not for salary fields, job families or departments, applicant data, or employers on a different ATS. --- # Workday Jobs Scraper Apify Actor `arman-bd/workday-jobs-scraper`. Paste the careers URLs exactly as they appear in a browser and get one dataset record per open requisition. Everything the run needs (tenant, datacentre and site ID) is parsed out of the URL, so nothing has to be looked up separately. It runs without credentials. ## When to use it - Snapshot thousands of enterprise requisitions across many employers in one run. - Diff `requisitionId` week over week to see what closed, what reopened and what is new. - Build an executive-search target list by narrowing on a keyword and a set of locations. - Measure which functions and geographies a competitor is investing in. - Ingest hundreds of corporate careers sites on a schedule into an aggregator. ## When not to use it - Salary or compensation as a structured field. Only what an employer typed into the description, and only when descriptions are switched on. - Job family, department or category per posting. Workday exposes those as filtering facets, not as values on a posting, so the Actor does not invent one. `timeType` and `remoteType` are the classification fields you actually get. - Employers on another ATS. A non-Workday careers URL cannot be parsed and is reported rather than attempted. - Applicant, candidate or pipeline data. Nothing account-scoped is reachable. ## Call it ```js import { ApifyClient } from 'apify-client'; const client = new ApifyClient({ token: process.env.APIFY_TOKEN }); const run = await client.actor('arman-bd/workday-jobs-scraper').call({ careerSites: [ 'https://nvidia.wd5.myworkdayjobs.com/NVIDIAExternalCareerSite', 'https://adobe.wd5.myworkdayjobs.com/external_experienced', ], searchText: 'software engineer', locationFilter: ['ireland', 'united kingdom'], maxJobsPerSite: 100, includeJobDetails: 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~workday-jobs-scraper/run-sync-get-dataset-items?token=$APIFY_TOKEN" \ -H "Content-Type: application/json" \ -d '{"careerSites":["https://nvidia.wd5.myworkdayjobs.com/NVIDIAExternalCareerSite"],"searchText":"engineer","maxJobsPerSite":25}' ``` The Actor is also exposed through Apify's MCP server as `arman-bd/workday-jobs-scraper`, so an MCP-capable agent can call it with no extra wiring. ## Input | Field | Type | Required | Default | Notes | |---|---|---|---|---| | `careerSites` | string[] | yes | | Careers URLs copied from the address bar. A locale segment such as `/en-US/` is stripped, trailing job paths are ignored, and a compact `tenant/wd5/site` form is also accepted. The datacentre segment (`wd1`, `wd3`, `wd5`, `wd12` and others) is assigned per employer and cannot be guessed, so it must come from the real URL, as must the employer-chosen site ID. Duplicates are collapsed; entries that cannot be parsed are skipped and listed in the summary. | | `searchText` | string | no | `""` | A single keyword string handed to Workday's own search **before** results are returned, so it cuts the number of pages fetched as well as the row count. Empty returns every open role. | | `locationFilter` | string[] | no | `[]` | Keep a job when its location text contains at least one of these, case-insensitive substring match. Applied **after** fetching, so it saves storage but not requests. | | `maxJobsPerSite` | integer | no | `200` | Stop paging a site once this many postings have been fetched. `0` means every posting the site publishes, and large tenants publish thousands. | | `includeJobDetails` | boolean | no | `false` | Fetch each posting's own record to add the description, exact posted date, time type and remote type. **One extra request per job.** | **`includeJobDetails` is the cost model and `searchText` is the lever against it.** Listing pages arrive 20 rows at a time, so a 2000-role tenant is 100 requests with details off and about 2100 with them on. `searchText` narrows at the source and therefore reduces both numbers; `locationFilter` narrows only the dataset. The cheap, correct shape for a recurring sweep is details off with a generous cap, then a second targeted run with details on over the handful of requisitions you actually care about. Never combine `includeJobDetails: true` with `maxJobsPerSite: 0` on a large employer without expecting a long run. ## Output One record per posting that survived `locationFilter`. | Field | Type | Notes | |---|---|---| | `tenant` | string | Employer slug parsed from the URL, lower-cased. | | `site` | string | The employer-chosen job-posting site ID, case preserved. One employer can run several. | | `company` | string | **The tenant slug again**, because the listing carries no separate display name. Do not present it as a company name without cleaning it up. | | `jobId` | string \| null | The posting-ID slug, for example `Senior-Engineer_JR2018715`. Stable across runs and the right primary key. | | `title` | string | Job title. | | `locationsText` | string \| null | Location exactly as Workday renders it. **For a requisition open at several sites this reads `2 Locations` or `3 Locations` rather than naming them**, which is also what `locationFilter` matches against. | | `location` | string | Resolved primary location. **Key absent** unless `includeJobDetails` is on and the detail fetch succeeded. | | `postedOn` | string \| null | Workday's relative label, for example `Posted 14 Days Ago`. Not a date. | | `postedDate` | string | Exact posting date. **Key absent** without job details. | | `timeType` | string | `Full time`, `Part time` and similar. **Key absent** without job details. | | `remoteType` | string | The employer's remote classification. **Key absent** without job details, and often absent even with them. | | `requisitionId` | string \| null | The employer's requisition number, for example `JR2018715`. Taken from the listing where present, otherwise from the detail record. | | `externalPath` | string | Workday's own path for the posting. Useful only for reconstructing links. | | `applyUrl` | string | Public apply link. Built from the tenant, datacentre and site when the detail record does not supply one. | | `description` | string | Full description as plain text, markup stripped and entities decoded. **Key absent** when `includeJobDetails` is false, and an empty string when the detail fetch failed. | | `scrapedAt` | string | Run timestamp, ISO 8601. | A real record from a run with `includeJobDetails: true`, description trimmed: ```json { "tenant": "nvidia", "site": "NVIDIAExternalCareerSite", "company": "nvidia", "jobId": "Director--Engineering---Software-Engineering_JR2021061", "title": "Director, Engineering, Software Engineering", "locationsText": "2 Locations", "location": "Vietnam, Hanoi", "postedOn": "Posted 27 Days Ago", "postedDate": "2026-07-10", "timeType": "Full time", "requisitionId": "JR2021061", "externalPath": "/job/Vietnam-Hanoi/Director--Engineering---Software-Engineering_JR2021061", "applyUrl": "https://nvidia.wd5.myworkdayjobs.com/NVIDIAExternalCareerSite/job/Vietnam-Hanoi/Director--Engineering---Software-Engineering_JR2021061", "description": "NVIDIA is widely recognized as one of the world's most desirable employers …", "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 where a partial run admits that it was partial, and where a mistyped URL surfaces. ```json { "sitesRequested": 2, "sitesFailed": 1, "unparseableInputs": ["https://boards.greenhouse.io/acme"], "failures": [ { "site": "adobe/wd5/wrong_site_id", "error": "adobe/wd5/wrong_site_id not found (404) …" } ], "jobsSaved": 187, "filters": { "searchText": "software engineer", "locationFilter": ["ireland", "united kingdom"], "maxJobsPerSite": 100, "includeJobDetails": true }, "finishedAt": "2026-08-06T12:04:31.902Z" } ``` Two different rejections are recorded separately. `unparseableInputs` holds entries that never looked like a Workday careers URL and cost no request at all. `failures` holds sites that were addressed and answered badly, with a `site` label of the form `tenant/datacentre/site`; a wrong site ID or datacentre lands here. `sitesRequested` counts only the parseable, de-duplicated sites, so it can be smaller than the list you sent. Note what is **not** here: there is no per-site job count, and **detail-fetch failures are not recorded at all**, only logged. See the caveat below. ## Behaviour to plan around - **Detail-fetch failures are invisible in the summary.** With `includeJobDetails` on, a job whose own record could not be read is still saved from the listing data, with `description` empty and `location`, `postedDate`, `timeType` and `remoteType` absent. Count rows missing those keys yourself if completeness matters. - **The optional fields are absent keys, not nulls.** Check for presence rather than for a falsy value, and do not assume a uniform schema across a mixed run. - **`locationsText` hides multi-site requisitions.** A req open in three countries reads `3 Locations`, so `locationFilter: ["london"]` will drop it even when London is one of them. Turn on `includeJobDetails` and filter on `location` afterwards when that matters. - **`postedOn` is a relative label, not a timestamp.** `Posted 30+ Days Ago` is as precise as it gets without job details. Use `postedDate` for anything time-based. - **`company` is the tenant slug.** It is lower-case and often not the trading name. - **Paging is fixed at 20 rows** and walks by offset until your cap, the advertised total or a short page. The advertised total is trusted only from the first page, because deep in the offset range Workday reports zero while still returning rows. - **`maxJobsPerSite` applies before `locationFilter`.** A cap of 100 with a location filter gives you the matches among the first 100 fetched, not 100 matches. Raise the cap or use `searchText`, which narrows at the source. - **One bad site never aborts the run.** A wrong tenant, datacentre or site ID is a not-found, lands in `failures`, and the others continue. The Actor only throws when every parseable site failed. - **Transient failures are retried** three times with exponential backoff. Not-found and malformed-request errors are final, because retrying a wrong site ID cannot help. - **Descriptions are decoded twice before markup is stripped**, since employers frequently paste already-encoded content in. You get real text rather than escaped tags. ## Recipes **Cheap change-detection sweep.** No descriptions, no cap, one request per 20 roles. ```json { "careerSites": ["https://nvidia.wd5.myworkdayjobs.com/NVIDIAExternalCareerSite"], "maxJobsPerSite": 0, "includeJobDetails": false } ``` Diff `requisitionId` against the previous run: disappearances are closures or fills, additions are new openings. **Targeted search with full text.** Keyword narrows at the source, location narrows after. ```json { "careerSites": ["https://salesforce.wd12.myworkdayjobs.com/External_Career_Site"], "searchText": "software engineer", "locationFilter": ["ireland", "united kingdom", "germany"], "maxJobsPerSite": 100, "includeJobDetails": true } ``` If this returns fewer rows than expected, the cap probably bit before the location filter did; raise `maxJobsPerSite` before loosening the filter. **Multi-employer hiring census.** Several tenants, listing data only. ```json { "careerSites": [ "https://nvidia.wd5.myworkdayjobs.com/NVIDIAExternalCareerSite", "https://adobe.wd5.myworkdayjobs.com/external_experienced", "https://salesforce.wd12.myworkdayjobs.com/External_Career_Site" ], "searchText": "", "maxJobsPerSite": 500, "includeJobDetails": false } ``` Group by `tenant` and `locationsText`, and check `unparseableInputs` is empty before comparing employers. **Enrich a narrow slice.** Second pass with descriptions, scoped tightly enough to afford one request per job. ```json { "careerSites": ["https://adobe.wd5.myworkdayjobs.com/external_experienced"], "searchText": "principal engineer", "maxJobsPerSite": 20, "includeJobDetails": true } ``` The narrower `searchText` is, the fewer listing pages and the fewer detail requests, so tighten it here rather than relying on `maxJobsPerSite` to stop the run.