--- name: personio-jobs-scraper description: Pull every open role from any Personio-hosted job board via the Apify Actor arman-bd/personio-jobs-scraper. Returns one record per vacancy with title, offices, department, employment type, seniority, schedule, years of experience, Personio's normalised occupation taxonomy, the salary band where the company published one, the section-by-section description and the direct apply URL. Use when a task needs DACH and Mittelstand hiring intelligence, job-board ingestion across many companies, hiring-signal lead generation or labour-market analysis. Not for candidate data, applications, closed roles or boards on other applicant tracking systems. --- # Personio Jobs Scraper: German Mittelstand Openings Apify Actor `arman-bd/personio-jobs-scraper`. Give it company subdomains, get one dataset record per open role. Each board is one request and is not paginated, so a board with hundreds of roles arrives whole. It takes no credentials. ## When to use it - DACH hiring intelligence: Mittelstand companies post here before the aggregators pick them up. - Ingesting hundreds of company boards on a schedule into your own job index. - Lead generation on hiring signals, where a role being open is the buying signal. - Labour-market analysis, using the fields Personio normalises across every tenant rather than free-text ones. - Watching a shortlist of employers for roles matching a title or location. ## When not to use it - Anything about candidates: applications, profiles, pipelines. Only public vacancies are returned. - Closed, filled or draft roles. The feed carries what is open now, with no history. - Companies on a different applicant tracking system. There is no fallback. - Salary benchmarking on its own. Most boards leave the salary fields empty and the Actor does not infer a band from the prose. ## Call it ```js import { ApifyClient } from 'apify-client'; const client = new ApifyClient({ token: process.env.APIFY_TOKEN }); const run = await client.actor('arman-bd/personio-jobs-scraper').call({ companies: ['circus', 'wandelbots'], locationFilter: ['berlin', 'remote'], includeDescription: true, language: 'en', }); 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~personio-jobs-scraper/run-sync-get-dataset-items?token=$APIFY_TOKEN" \ -H "Content-Type: application/json" \ -d '{"companies":["1komma5grad"],"departmentFilter":["tech"],"language":"en","maxJobsPerCompany":25}' ``` The Actor is also exposed through Apify's MCP server as `arman-bd/personio-jobs-scraper`, so an MCP-capable agent can call it with no extra wiring. ## Input | Field | Type | Required | Default | Notes | |---|---|---|---|---| | `companies` | string[] | yes | | Company subdomains, or any board URL containing one. A URL is reduced to the subdomain automatically, the result is lower-cased and duplicates are dropped. An entry that is neither is passed through as-is and will come back as a failure. If nothing survives, the run throws. | | `searchTerms` | string[] | no | `[]` | Keep only jobs whose **title** contains one of these, case-insensitive substring. Titles on DACH boards are often German, so include both languages. Empty keeps all. | | `locationFilter` | string[] | no | `[]` | Matched against the primary office **and** every additional office joined together, so a remote role with a Berlin office matches either term. Empty keeps all. | | `departmentFilter` | string[] | no | `[]` | Matched against `department` and `recruitingCategory` joined together, which is why it still works on boards that leave `department` blank. Empty keeps all. | | `maxJobsPerCompany` | integer | no | `0` | Cap per company, applied **after** filtering. `0` means no cap. Minimum `0`. | | `includeDescription` | boolean | no | `true` | Include `descriptionSections` and `descriptionPlain`. Off gives a much smaller dataset with every other field intact. | | `language` | string | no | `""` | Two-letter code. Returns translated text where the company published one and the original where it did not. Also appended to `applyUrl`. Empty uses the board's default, usually German. | | `domain` | string | no | `"de"` | `de` or `com`. Both serve the same feed, so this only matters when a company's board is published under one and not the other. | **Filtering is free, description is not.** The whole feed arrives in one request whether you filter or not, so `searchTerms`, `locationFilter`, `departmentFilter` and `maxJobsPerCompany` are cost and dataset-size controls rather than speed controls: they decide how many rows you store, not how much is fetched. The setting that genuinely changes the weight of a run is `includeDescription`. Off, a hundred-company sweep is small enough to run hourly and still gives you `jobId`, `title`, `office` and `createdAt` to diff on. On, you get the full text for the roles you actually care about. The efficient pattern is a cheap sweep followed by a narrow second run. ## Output One record per open role that survived the filters and the cap. | Field | Type | Notes | |---|---|---| | `company` | string | The subdomain, normalised. Join your input list on this. | | `jobId` | number \| null | Personio's position id, converted to a number. | | `title` | string \| null | Job title in the feed's language. | | `subcompany` | string \| null | The legal entity the role sits under, often different from `company`. | | `office` | string \| null | Primary location. | | `additionalOffices` | string[] | Every other office the role is open in. Empty for single-site roles. | | `department` | string \| null | The company's own department label. Free text, and frequently empty. | | `recruitingCategory` | string \| null | The company's recruiting-category label. Often populated where `department` is not. | | `employmentType` | string \| null | Personio's enumeration, for example `permanent`, `intern`, `trainee`. | | `seniority` | string \| null | Personio's enumeration: `entry-level`, `experienced`, `senior`, `lead`, `student`, `intern`. | | `schedule` | string \| null | For example `full-time`, `part-time`, `full-or-part-time`. | | `yearsOfExperience` | string \| null | Banded: `lt-1`, `1-2`, `2-5`, `5-7`, `7-10`, `gt-10`. | | `occupation` | string \| null | Personio's normalised occupation, for example `bookkeeping`. | | `occupationCategory` | string \| null | Its parent category, for example `accounting_and_finance`. | | `keywords` | string[] | The company's own search keywords, split from a comma-separated string. Empty when none were set. | | `salaryMin` | number \| null | Populated only where the company filled the salary fields in. | | `salaryMax` | number \| null | Same. | | `salaryCurrency` | string \| null | For example `EUR`. | | `salaryPeriod` | string \| null | For example `yearly`. | | `createdAt` | string \| null | Posting timestamp with a numeric offset, for example `2026-07-23T16:58:07+00:00`, not a `Z` suffix. | | `descriptionSections` | object[] | **Key is absent entirely when `includeDescription` is false.** Otherwise `{ name, text }` blocks keeping the board's own headings, HTML stripped to text. | | `descriptionPlain` | string | Same absence rule. The sections flattened, each heading followed by its text. | | `applyUrl` | string | Direct apply link, carrying `language` when you set one. | | `scrapedAt` | string | Run timestamp, ISO 8601. | A real record, description abridged: ```json { "company": "circus", "jobId": 2726425, "title": "(Senior) Financial Accountant / Bilanzbuchhalter:in (f/m/d)", "subcompany": "Circus SE", "office": "Munich", "additionalOffices": [], "department": "Finance & Legal", "recruitingCategory": "HQ", "employmentType": "permanent", "seniority": "experienced", "schedule": "full-or-part-time", "yearsOfExperience": "2-5", "occupation": "bookkeeping", "occupationCategory": "accounting_and_finance", "keywords": ["accounting", "bilanzbuchhalter", "buchhaltung", "financial accountant"], "salaryMin": null, "salaryMax": null, "salaryCurrency": null, "salaryPeriod": null, "createdAt": "2026-07-23T16:58:07+00:00", "descriptionSections": [ { "name": "Our Mission", "text": "At Circus (Xetra: CA1), headquartered in Munich, we are pioneering …" }, { "name": "About the Role", "text": "We are looking for a motivated, professional Financial Accountant …" }, { "name": "Your Daily Business", "text": "• Independent management of financial accounting …" } ], "descriptionPlain": "Our Mission\n\nAt Circus (Xetra: CA1), headquartered in Munich, we are pioneering …", "applyUrl": "https://circus.jobs.personio.de/job/2726425", "scrapedAt": "2026-08-06T12:03:13.185Z" } ``` A multi-site role carries every location it is open in, and a company that published a band fills the four salary fields: ```json { "title": "(Junior) Data Engineer - Data Platform (m/f/d)", "office": "Remote", "additionalOffices": ["Hamburg", "Berlin", "München"], "salaryMin": 45000, "salaryMax": 55000, "salaryCurrency": "EUR", "salaryPeriod": "yearly" } ``` ## 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": 5, "companiesFailed": 1, "failures": [ { "company": "zzzqqqnotarealcompany", "error": "no Personio board at \"zzzqqqnotarealcompany\" (404), check the subdomain" } ], "boardsWithNoOpenRoles": ["moss"], "jobsListed": 20, "jobsSaved": 9, "filters": { "searchTerms": [], "locationFilter": [], "departmentFilter": [], "maxJobsPerCompany": 3, "includeDescription": true, "language": null, "domain": "jobs.personio.de" }, "finishedAt": "2026-08-06T12:03:13.190Z" } ``` Three outcomes are deliberately kept apart, and an agent should not collapse them. `failures` is a board that could not be read, usually a wrong subdomain. `boardsWithNoOpenRoles` is a board that answered perfectly and has nothing open, which is a hiring signal rather than an error. And `jobsListed` above `jobsSaved` with neither list populated means your filters or your cap removed the difference. Check `filters` to confirm the run used the terms you intended before concluding a company is not hiring. ## Behaviour to plan around - **The three filters combine with AND, terms within one combine with OR.** A job must match at least one term in every non-empty filter. That is the most common reason a run comes back empty while `jobsListed` is large. - **The cap runs last.** Filter, then cap, so `maxJobsPerCompany: 25` with a title filter gives 25 matching roles rather than 25 roles of which some match. - **`descriptionSections` and `descriptionPlain` are absent, not empty**, when `includeDescription` is off. Test for the key. - **`department` is free text, the taxonomy fields are not.** `seniority`, `yearsOfExperience`, `schedule`, `employmentType`, `occupation` and `occupationCategory` use the same enumerations on every tenant, which makes them the only fields worth aggregating across companies. Do not group by `department` across employers and expect it to mean anything. - **`createdAt` uses a numeric offset, not `Z`.** Parse it as ISO 8601 with offset; a regex expecting a trailing `Z` will miss. - **An empty board is not a failure.** It lands in `boardsWithNoOpenRoles` and the run continues. An unknown subdomain, by contrast, is reported in `failures`. - **Salary is usually null.** Four nulls means the company left the fields blank, not that the role is unpaid, and the description text is not mined for a number. - **`language` is best-effort.** Untranslated postings come back in the original language, mixed into the same dataset. Nothing is translated by the Actor. - **Boards are fetched sequentially, one request each**, with a short pause between them, so hundreds of companies per run is normal and predictable. - **One bad board never aborts the run.** The Actor throws only when every board failed. Transient failures are retried three times; a missing board is final. ## Recipes **Cheap hourly sweep for change detection.** No descriptions, no filters. ```json { "companies": ["circus", "wandelbots", "1komma5grad"], "includeDescription": false } ``` Diff on `company` plus `jobId`, and treat a disappeared `jobId` as a closed role. **Targeted search across boards.** Bilingual title terms, location and department narrowed, English text where it exists. ```json { "companies": ["1komma5grad", "https://circus.jobs.personio.de"], "searchTerms": ["engineer", "entwickler"], "locationFilter": ["hamburg", "remote"], "departmentFilter": ["tech"], "maxJobsPerCompany": 50, "language": "en" } ``` Send candidates straight to `applyUrl`, which already carries the language. **Labour-market aggregation.** Everything, descriptions off, grouped on the normalised fields. ```json { "companies": ["circus", "wandelbots", "moss", "1komma5grad"], "includeDescription": false, "maxJobsPerCompany": 0 } ``` Group by `occupationCategory`, `seniority` and `yearsOfExperience`; ignore `department`. **Full text for a shortlist.** Second pass over the roles the cheap sweep flagged. ```json { "companies": ["circus"], "searchTerms": ["accountant"], "includeDescription": true, "language": "en" } ``` Feed `descriptionPlain` to a classifier, or keep `descriptionSections` when you need the requirements block on its own.