--- name: teamtailor-jobs-scraper description: Read every open role from any Teamtailor career site via the Apify Actor arman-bd/teamtailor-jobs-scraper. Returns one record per job with the company name, numeric job id, title, department, location, remote status, employment type, publish timestamp, direct apply link and the full description as clean plain text. Use when a task needs European and Nordic hiring data that US-centric boards miss, a competitor headcount watch, a job aggregator feed, or change detection across career sites on a schedule. Not for salary data, applicant or candidate information, closed or draft roles, or boards on any other applicant tracking system. --- # Teamtailor Jobs Scraper Apify Actor `arman-bd/teamtailor-jobs-scraper`. Give it Teamtailor career-site subdomains, get one dataset record per open role. No credentials and no proxy configuration are involved. ## When to use it - Nordic and wider European hiring data, where Teamtailor's customer base is unusually deep and US-centric boards have nothing. - A competitor headcount watch: `department` plus `publishedAt` shows where hiring is going, month by month. - Feeding a job aggregator: one scheduled run refreshes every listing you syndicate. - Change detection: diff on `jobId` plus `publishedAt` between runs to get openings and closures. - Lead generation, on the premise that a company hiring for a role is a company buying for it. ## When not to use it - Salary or compensation data. There is no structured salary field, so anything you get is whatever the recruiter typed into the description. - Candidates, applicants, recruiters or any personal data. Public postings only. - Closed, expired, draft or internal roles. Only what the career site publishes. - Greenhouse, Lever, Workable, Ashby, Workday, Personio or any other applicant tracking system. Those need their own Actors. - Discovering which companies use Teamtailor. You have to supply the subdomains. ## Call it ```js import { ApifyClient } from 'apify-client'; const client = new ApifyClient({ token: process.env.APIFY_TOKEN }); const run = await client.actor('arman-bd/teamtailor-jobs-scraper').call({ companies: ['polestar', 'instabee'], searchTerms: ['engineer', 'developer'], locationFilter: ['stockholm'], maxJobsPerCompany: 50, fetchJobDetails: 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~teamtailor-jobs-scraper/run-sync-get-dataset-items?token=$APIFY_TOKEN" \ -H "Content-Type: application/json" \ -d '{"companies":["polestar"],"searchTerms":["engineer"],"maxJobsPerCompany":25}' ``` The Actor is also exposed through Apify's MCP server as `arman-bd/teamtailor-jobs-scraper`, so an MCP-capable agent can call it with no extra wiring. ## Input | Field | Type | Required | Default | Notes | |---|---|---|---|---| | `companies` | string[] | yes | | Career-site subdomains, lowercased and de-duplicated. The subdomain is the company slug in the career-site address, so `polestar` for `polestar.teamtailor.com`. A full career-site URL is accepted and reduced to the slug. An employer fronting the site with a custom domain is still reached by its subdomain. An empty list throws. | | `searchTerms` | string[] | no | `[]` | Keep only jobs whose title contains at least one of these, case-insensitive substring match. Empty keeps every title. | | `locationFilter` | string[] | no | `[]` | Keep only jobs whose location contains at least one of these, case-insensitive substring match. Empty keeps every location. | | `maxJobsPerCompany` | integer | no | `0` | Cap on jobs saved per company. `0` means no limit. Also stops enumeration early, which has a consequence worth reading below. | | `fetchJobDetails` | boolean | no | `true` | Fetch each saved job's own page to fill in `employmentType`, and to supply `bodyPlain` for roles beyond the structured feed. One extra request per saved job. | The two filters combine with AND: a job must match at least one term in *each* non-empty filter. **The interaction to plan around is `maxJobsPerCompany` against the filters.** The cap is applied twice: it stops the enumeration of the board once that many roles have been *listed*, and it then trims the *matched* set. With filters set, a low cap can therefore return fewer matches than the cap allows, because enumeration stopped before it reached them. When you are filtering, either leave the cap at `0` or set it well above the number of matches you expect. `fetchJobDetails` is the cost multiplier: it turns a board sweep into one request per saved job, so set the cap first and the switch second. ## Output One record per saved job. | Field | Type | Notes | |---|---|---| | `company` | string | Company name as the career site publishes it, whitespace trimmed. Falls back to the subdomain when the site publishes no organisation name. | | `jobId` | number | Teamtailor's numeric job id, stable across runs. The join key. | | `title` | string \| null | Job title as shown on the listing. | | `department` | string \| null | Team the role sits in. `null` when the career site's card does not show one, which is common on custom layouts. | | `location` | string \| null | Location as displayed, falling back to a structured address assembled as `locality, region, country`. | | `remoteStatus` | string \| null | `Hybrid`, `Fully Remote`, `On-site` and similar. `null` when the employer left the field unset, which is the majority. | | `employmentType` | string \| null | Schema.org value such as `FULL_TIME`, `PART_TIME`, `CONTRACTOR`. Always `null` when `fetchJobDetails` is off. | | `publishedAt` | string \| null | Publish timestamp with a timezone offset, for example `2026-08-06T03:51:46+02:00`. Not UTC. | | `applyUrl` | string | Direct link to the job page, query string stripped. | | `bodyPlain` | string | Full description as plain text: tags stripped, entities decoded, list items prefixed with a bullet. Empty string, not `null`, when no source had a description. | | `scrapedAt` | string | Run timestamp, ISO 8601 UTC. | A real record, description trimmed: ```json { "company": "Polestar", "jobId": 8176726, "title": "Product Quality Expert", "department": "Quality & Logistics", "location": "Shanghai, China", "remoteStatus": null, "employmentType": "FULL_TIME", "publishedAt": "2026-08-06T03:51:46+02:00", "applyUrl": "https://polestar.teamtailor.com/jobs/8176726-product-quality-expert", "bodyPlain": "We are Polestar. We are detail obsessed, performance focused …", "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. ```json { "companiesRequested": 3, "companiesFailed": 1, "failures": [ { "company": "notacompany", "error": "notacompany feed not found (404)" } ], "jobsSaved": 128, "filters": { "searchTerms": ["engineer"], "locationFilter": ["stockholm"], "maxJobsPerCompany": 50, "fetchJobDetails": true }, "finishedAt": "2026-08-06T12:00:31.220Z" } ``` `companiesRequested` counts subdomains after normalisation and de-duplication, so two spellings of one career site count once. Every entry in `failures` is a whole board that produced nothing, named by subdomain. Note two absences: `filters` does not echo `companies`, so keep your own list, and per-job detail failures never appear here. A job whose own page could not be fetched is still saved, with `employmentType` `null` and possibly an empty `bodyPlain`, and the only trace is a warning in the run log. ## Behaviour to plan around - **`maxJobsPerCompany` truncates enumeration, not just output.** Combined with `searchTerms` or `locationFilter` it can return fewer matches than the cap. Leave it at `0` whenever you filter and care about completeness. - **Boards larger than 100 roles are enumerated in full**, but the structured half of the data covers only the first 100. Roles past that point get their description from their own page, so with `fetchJobDetails` off they come back with an empty `bodyPlain`. A 104-role board still returns 104 rows either way. - **`bodyPlain` is an empty string, never null.** Testing `if (job.bodyPlain)` is the right check; testing for `null` is not. - **`employmentType` is only ever populated with `fetchJobDetails` on.** Do not read its absence as "the employer did not say". - **`remoteStatus` comes from the listing first.** The job page labels that field in English only, so on a localised career site the listing is the reliable source and the detail fetch adds nothing. Most employers leave it unset regardless. - **`publishedAt` carries a local offset.** Convert to UTC before comparing dates across companies in different countries, or a same-day posting will look like two days. - **`department` is genuinely optional.** Its absence says nothing about the role; the card layout simply did not carry it. `location` is far more reliably present, because it falls back to a structured address. - **A board whose listing yields nothing falls back to the structured feed**, which caps at 100 roles and returns `department` and `remoteStatus` as `null` throughout. A run where every record has both fields null across a large board is the signature. - **One unreachable job page never costs you the board**, and one failed board never costs you the run. Transient failures are retried three times with exponential backoff; a missing career site is final. The run throws only when every board failed. - **Descriptions are decoded twice on purpose.** Recruiters paste already-encoded markup, so `bodyPlain` is real text rather than a string full of escaped tags. ## Recipes **Full sweep of a competitor set.** No filters, no cap, descriptions included. ```json { "companies": ["polestar", "instabee", "nordicwellness"], "fetchJobDetails": true } ``` Group by `company` and `department` for a headcount picture, and keep `bodyPlain` for keyword analysis of the roles they are opening. **Fast change detection.** Details off makes this cheap enough to run hourly. ```json { "companies": ["polestar", "instabee"], "fetchJobDetails": false } ``` Diff the set of `jobId` values against the previous run: new ids are openings, disappeared ids are closures. `employmentType` will be `null` throughout, which is expected here. **Targeted search across several boards.** Cap left at zero so filtering stays complete. ```json { "companies": ["polestar", "instabee"], "searchTerms": ["engineer", "developer", "utvecklare"], "locationFilter": ["stockholm", "göteborg"], "maxJobsPerCompany": 0 } ``` Include local-language terms: titles are published in whatever language the employer uses, and the match is a plain substring, not a translation. **Aggregator refresh.** One board, everything, on a schedule. ```json { "companies": ["polestar"], "fetchJobDetails": true, "maxJobsPerCompany": 0 } ``` Upsert on `jobId`, use `applyUrl` as the outbound link and treat any id missing from the newest run as filled or withdrawn.