# Job Feed: 110+ Job Boards & Career Pages in 60+ Countries (`santamaria-automations/job-feed`) Actor

One deduplicated, newest-first job feed from 110+ job boards in 60+ countries, 40+ ATS career systems and any company career page. Deepest in Switzerland, Germany and Austria. Daily new-jobs-only runs, radius filter, agency flag, and MCP for AI agents.

- **URL**: https://apify.com/santamaria-automations/job-feed.md
- **Developed by:** [NanoScrape](https://apify.com/santamaria-automations) (community)
- **Categories:** Jobs, Lead generation
- **Stats:** 3 total users, 2 monthly users, 100.0% runs succeeded, 0 bookmarks
- **User rating**: No ratings yet

## Pricing

$0.50 / 1,000 unique job in aggregated feeds

This Actor is paid per event. You are not charged for the Apify platform usage, but only a fixed price for specific events.

Learn more: https://docs.apify.com/actors/running/actors-in-store.md#pay-per-event

## What's an Apify Actor?

An Actor is a serverless cloud program that runs on the Apify platform. It has two run modes.
In Batch mode, an Actor accepts a well-defined JSON input, performs an action which can take anything from a few seconds to a few hours,
and optionally produces a well-defined JSON output, datasets with results, or files in key-value store.
In Standby mode, an Actor provides a web server which can be used as a website, API, or an MCP server.

Apify vocabulary and the platform model are defined once, in the agent quickstart at https://apify.com/agents.md.

## How to integrate an Actor?

If asked about integration, you help developers integrate Actors into their projects.
You adapt to their stack and deliver integrations that are safe, well-documented, and production-ready.

Do not guess an integration path. Every one of them is in the agent quickstart at https://apify.com/agents.md: the Apify MCP server, Agent Skills with the Apify CLI, the JavaScript and Python clients, the REST API, and the account-free path for an agent with no human to sign in. It also carries the rule on stating cost before the first paid run.

For examples already wired to this Actor's own input schema, see the [API](#api) section below.

Each client library has reference documentation the quickstart does not restate: [JavaScript/TypeScript](https://docs.apify.com/api/client/js/docs.md) (`npm install apify-client`) and [Python](https://docs.apify.com/api/client/python/docs.md) (`pip install apify-client`).

# README

## Job Feed - Job Aggregator for Recruiters, Job Seekers and AI Agents

One deduplicated, newest-first job feed from 110+ job boards ([LinkedIn](https://www.linkedin.com/jobs/), [Indeed](https://www.indeed.com/), [StepStone](https://www.stepstone.de/), [Jobs.ch](https://www.jobs.ch/), [Arbeitsagentur.de](https://www.arbeitsagentur.de/), [USAJOBS](https://www.usajobs.gov/) and many more), 40+ ATS career systems and any company career page. One input, one schema, one stable `job_id`. No login needed.

**Who it is for**

- **Recruiters and agencies** (deepest coverage in CH, DE and AT) who want new roles at direct employers, hiring signals and a daily list instead of ten browser tabs.
- **Job seekers** who want a daily alert for a role, city and radius.
- **HR-tech and AI-agent builders** who need one normalized job schema, a stable key to upsert on, and an MCP tool.

**What you get**

- One feed across boards, deduplicated, newest first, each row with a stable `job_id` (same job from any board hashes the same).
- `onlyNewJobs`: a daily schedule delivers (and bills) only jobs you have not received before.
- A radius filter (`location` + `radiusKm`) that also drops rows boards return too far away.
- An agency flag on every company (`company.is_agency`) and `excludeAgencies` to drop staffing firms and job aggregators.
- Company career pages: add `companyUrls` and get that employer's own openings, including company, career page and employee range.

**For recruiters: a copy-paste search**

```json
{"countries":["CH"],"keywords":["Pflegefachfrau","Pflegefachmann"],"location":"Zürich","radiusKm":30,"postedWithinDays":7,"excludeAgencies":true,"maxResultsTotal":200}
```

Measured (local run, October 2026): 98 unique direct-employer jobs from 6 boards (jobs.ch, arbeit.swiss, jobscout24, Indeed, LinkedIn, Glassdoor); 138 board rows merged to 98, 10 agency postings left out, 24 jobs outside 30 km dropped. Cost about $0.38 for the run ($0.33 sub-actors, $0.05 job-feed), about $0.004 per job. Weekly that is about $1.65 a month, daily at most about $11; with `onlyNewJobs` the job-feed part drops to new jobs only while board scraping still applies.

**How to start**

1. Pick `countries` (for example `["CH"]`), `keywords` and a `location`.
2. Start; the prefilled input (Zürich, Software Engineer, 30 km, last 7 days) works as is.
3. Schedule it daily with `"onlyNewJobs": true`, and add `"excludeAgencies": true` if you only want direct employers.

### What it costs

Pay-per-event, no subscription. job-feed bills $0.0005 per unique job it delivers. The boards and career-site scrapers it calls are separate actors, **billed separately to your Apify account at their own rates, and included in the totals below**. Rows job-feed drops before delivery (caps, filters, duplicates) are not billed by job-feed, though a board may have billed its own event for them.

| Search | Per run | Per month |
|---|---|---|
| **CH, Pflegefachfrau/-mann, Zürich, 30 km**, `excludeAgencies`, 98 unique jobs (measured, October 2026) | about $0.38 | about $1.65 weekly, at most about $11 daily |
| **CH, Laborant, Basel**, 6 boards plus `jobbasel-ch`, 25 unique jobs (measured, October 2026) | about $0.11 | about $3.30 daily (30 runs) |
| **50-company career-site list** (`careerSites`, known ATS, about 12 open roles each, about 600 jobs), weekly (estimate) | about $0.90 | about $3.60 (4 runs) |

The measured runs use list prices from each board's own rate (for example `jobs-ch` $0.00287 per row, `arbeit-swiss` and `jobscout24-ch` $0.003, `career-site-jobs` $0.001 per job) plus job-feed's $0.0005 per unique job; the career-site row is an estimate. See [Supported boards](#supported-boards) for board prices and [Billing and cost model](#billing-and-cost-model) for the `cost_report` of each run. On custom pages (LLM extractor, about $0.012 per job plus $0.01 per company) the same 50 companies cost around $8 for the first run (estimate). `maxResultsTotal` caps spend.

### Use cases

- **Agency sourcing of new roles at direct employers.** `countries: ["CH","DE"]`, your keywords, `excludeAgencies: true`, `onlyNewJobs: true`, daily. Staffing firms and aggregators (DACH agency registry) are dropped.
- **Hiring-signal leads for sales.** Run a role keyword (for example "DevOps Engineer") with `includeCompanyDetails`, and rows carry the company name, domain, `career_page_url` and, where the source reports it, an employee range (`employee_count_from`, `employee_count_to`).
- **A daily alert for a job seeker.** One city, `radiusKm: 25`, `postedWithinDays: 1`, `onlyNewJobs: true`, scheduled each morning.
- **Life-science niche search.** Combine regional boards (`includeRegionalBoards: "nearby"` for a Basel or Zürich `location`) with a list of employer career pages in `careerSites`, so small cantonal boards and employer ATS pages land in one feed.
- **Feeding an AI agent.** Connect the actor over MCP (below) and let your agent search jobs by country, keyword and location; `job_id` tells it what it has already shown.

### Example output

One row from the Pflege run, trimmed:

```json
{
  "job_id": "edf36ab3f7686037",
  "title": "Pflegefachperson für die Viszeralchirurgie / Gastroenterologie 50 -100%",
  "company": {"name": "Universitätsspital Zürich", "is_agency": false, "agency_type": null},
  "location": {"raw": "Zurich", "city": "Zürich", "region": "ZH", "country": "CH"},
  "employment_type": "permanent",
  "workload_pct": {"min": 50, "max": 100},
  "posted_date": "2026-10-16T12:31:19.714Z",
  "language": "de",
  "matched_query": "Pflegefachmann",
  "apply_url": "https://www.jobscout24.ch/en/job/89adc54d-9f7b-4fca-8557-716fd099eab0/",
  "sources": [{"platform": "jobscout24-ch", "url": "https://www.jobscout24.ch/en/job/89adc54d-9f7b-4fca-8557-716fd099eab0/"}],
  "_platform": "jobscout24-ch"
}
```

### Use with AI Agents (MCP)

Connect this actor to any MCP-compatible AI client.

**Apify MCP server URL:**

```
https://mcp.apify.com?tools=santamaria-automations/job-feed
```

For clients that only speak stdio, use `mcp-remote`:

```json
{
  "mcpServers": {
    "job-feed": {
      "command": "npx",
      "args": ["-y", "mcp-remote", "https://mcp.apify.com?tools=santamaria-automations/job-feed", "--header", "Authorization: Bearer YOUR_APIFY_TOKEN"]
    }
  }
}
```

### How it works

Job Feed is an orchestrator over three tiers of scrapers:

1. **110+ job boards**, each with a scraper built per site (LinkedIn, Indeed, StepStone, Jobs.ch, Arbeitsagentur.de, USAJOBS and many more). Selected via `platforms` or `countries`.
2. **`career-site-jobs`** - direct extraction from 40+ ATS career systems ([Greenhouse](https://www.greenhouse.io/), [Lever](https://www.lever.co/), [Workday](https://www.workday.com/), [Ashby](https://www.ashbyhq.com/), [Teamtailor](https://www.teamtailor.com/), [Personio](https://www.personio.com/), [SmartRecruiters](https://www.smartrecruiters.com/), [BambooHR](https://www.bamboohr.com/), [iCIMS](https://www.icims.com/), [Recruitee](https://recruitee.com/), and more). Activated when a `companyUrls` entry matches a known ATS, or explicitly with `includeCareerSites: true`.
3. **`website-job-extractor`** - reads any other company website: it finds the careers page, detects the ATS, and falls back to LLM extraction plus a browser retry for JS-rendered sites.

More boards and career systems are added continuously.

### Table of contents

- [What it costs](#what-it-costs)
- [Use cases](#use-cases)
- [Example output](#example-output)
- [Use with AI Agents (MCP)](#use-with-ai-agents-mcp)
- [How it works](#how-it-works)
- [Quick start](#quick-start)
- [Constraints](#constraints)
- [Supported platforms](#supported-platforms)
- [Supported boards](#supported-boards)
- [Coming soon](#coming-soon)
- [Limitations](#limitations)
- [Input reference](#input-reference)
- [Output reference](#output-reference)
- [How job IDs and dedup work](#how-job-ids-and-dedup-work)
- [Billing and cost model](#billing-and-cost-model)
- [Rate card](#rate-card)
- [Examples](#examples)
- [FAQ](#faq)
- [Troubleshooting](#troubleshooting)
- [Regional boards](#regional-boards)
- [Registry](#registry)
- [Related actors](#related-actors)
- [Run job-feed from code](#run-job-feed-from-code)
- [API and Apify Console](#api-and-apify-console)

### Quick start

```json
{
  "countries": ["DE", "AT", "CH"],
  "keywords": ["Software Engineer"],
  "maxResultsPerQueryPerPlatform": 25
}
```

That fires every Tier 1 platform in DACH plus Indeed, Glassdoor and LinkedIn per country (`includeGlobalBoards`, on by default). Reports are in the OUTPUT record, see [Terminal items](#terminal-items-in-the-output-record).

### Constraints

- **Platforms per run:** 110+ selectable in the `platforms` picker; the orchestrator gates concurrency against a RAM budget, capped by `maxTotalMemoryMbytes` if you set it (see [RAM and parallelism](#ram-and-parallelism)). Browser-tier actors use up to 4 GB each and queue when budget is tight.
- **Sub-actor timeout:** 60-3600 seconds per platform (default 600).
- **titleFilter:** comma-separated substring list, case-insensitive OR match. Ten boards also filter natively before detail fetch at no extra cost, and every row is then checked against the same rule here (see [Platform selection](#platform-selection)); the rest are filtered client-side, counted as `dropped_title_filter`.
- **postedWithinDays precision:** accepts fractional days: `0.5` = last 12 hours, `0.042` is roughly 1 hour, use `1.0/24` for exactly 1 hour.
- **Dedup mode:** `streaming` (default, rows flow as each platform finishes) or `batch` (buffer until all platforms complete, then emit once per unique `job_id` with all sources inline). See [Dedup mode](#dedup-mode).

### Supported platforms

The registry tracks 120+ platforms. 110+ are healthy and selectable in the `platforms` picker: 110+ job boards plus 3 company-website sources (`career-site-jobs`, `website-job-extractor`, `website-job-extractor-browser`). The rest are parked or deliberately excluded and never selectable (mostly aggregator wrappers over sources this fleet already scrapes first-party, such as Adzuna, Jooble, Careerjet, and Monster). See [Coming soon](#coming-soon) for what is in the build queue.

Every board has a `tier`: **Tier 1** boards run automatically when their country is in `countries[]`; **Tier 2** boards run only when named in `platforms[]`. The full list, with each board's standalone Apify actor, RAM and pricing, is in [Supported boards](#supported-boards).

#### Country presets

`countries: ["XX"]` turns on every healthy Tier 1 board for that market (table below). It is additive across countries, and additive with anything you list in `platforms[]`. Tier 2 boards are not in the preset and need to be named explicitly (or added with `includeRegionalBoards`). When `platforms[]` is also set, countries still add their preset and the global boards, and `run_summary` warnings name every board added that way. `GB` is accepted as an alias for `UK`. Countries not in the table have no preset, use `platforms` instead.

`includeGlobalBoards` (**default `true`**) additionally runs [Indeed](https://www.indeed.com/), [Glassdoor](https://www.glassdoor.com/), and [LinkedIn](https://www.linkedin.com/jobs/) once **per selected country**: `countries: ["DE","AT"]` fires two Indeed runs, one scoped to DE and one to AT. Boards you name in `platforms[]` are not added twice. Set it to `false` to run only the preset.

| Country | Preset (Tier 1, automatic) |
| --- | --- |
| Germany (DE) | `arbeitsagentur-de`, `jobware-de`, `meinestadt-de`, `stellenanzeigen-de`, `stepstone-de` |
| Austria (AT) | `ams-at`, `karriere-at`, `stepstone-at` |
| Switzerland (CH) | `arbeit-swiss`, `jobs-ch`, `jobscout24-ch` |
| United Kingdom (UK / GB) | `nhs-jobs`, `reed-uk` |
| United States (US) | `usajobs-us`, `wellfound-us`, `workatastartup-us` |
| Canada (CA) | `jobbank-ca` |
| Australia (AU) | `seek` |
| Belgium (BE) | `forem-be`, `vdab-be` |
| Czech Republic (CZ) | `prace-cz` |
| Denmark (DK) | `jobindex-dk` |
| Spain (ES) | `infojobs-es`, `sepe-es` |
| France (FR) | `france-travail`, `hellowork-fr`, `welcome-to-the-jungle` |
| Greece (GR) | `kariera-gr` |
| Croatia (HR) | `mojposao-hr` |
| Hungary (HU) | `profession-hu` |
| Ireland (IE) | `irishjobs-ie` |
| Italy (IT) | `subito-it-jobs` |
| Netherlands (NL) | `nationale-vacaturebank-nl`, `werk-nl` |
| Norway (NO) | `finn-no` |
| Poland (PL) | `justjoinit`, `nofluffjobs-eu`, `pracuj-pl` |
| Portugal (PT) | `net-empregos-pt` |
| Romania (RO) | `anofm-ro`, `ejobs-ro` |
| Russia (RU) | `hh-ru`, `trudvsem-ru` |
| Sweden (SE) | `arbetsformedlingen-se` |
| Slovakia (SK) | `profesia-sk` |
| Ukraine (UA) | `dcz-gov-ua` |

**Tier 2 boards (opt-in, name them in `platforms[]`):**

- **DE:** `stellenmarkt-de`, `nicejob-de`. **AT:** `westjob-at`. **PL:** `praca-gov-pl`. **UA:** `work-ua`.
- **CH:** `jobbasel-ch`, `ostjob-ch`, `jobzueri-ch`, `jobbern-ch`, `jobmittelland-ch`, `zentraljob-ch`, `myjob-ch`, `schaffu-ch`, `sozjobs-ch`, `ictjobs-ch`, `swissdevjobs-ch`, `carriera-ch`, `jobup-ch`, `alpha-topjobs-ch`, `lavoro-cdt-ch`, `ge-ch-emploi`.
- **UK:** `totaljobs-uk`, `tes-uk`, `jobserve-uk`, `charityjob-uk`, `guardianjobs-uk`, `teaching-vacancies-uk`, `targetjobs-uk`, `jobs-ac-uk`, `escape-the-city`, `cwjobs-uk`, `prospects-uk`, `hays-uk`, `milkround-uk`.
- **US:** `healthecareers-us`, `levels-fyi-us`, `rigzone-us`, `wayup-us`, `snagajob-us`, `craigslist-jobs`, `dribbble-jobs`, `biospace`, `higheredjobs`.
- **No country preset:** `naukri` (India), `jobkorea-kr` (South Korea), `bayt-me`, and the international or remote boards (`linkedin`, `glassdoor`, `efinancialcareers`, `jobrapido`, `computrabajo`, `idealist`, `ycombinator`, `michaelpage-intl`, `remoteok`, `remotive`, `weworkremotely`, `dice`, `builtin`, `cryptojobslist`, `web3career`, `e-fellows`, `euraxess`).
- **Research jobs (EU-wide):** `euraxess` (PhD, postdoc, research staff, lab technicians) belongs to no preset and is never added by `includeGlobalBoards`; name it in `platforms[]`. `countries` are forwarded to its own country filter (OR-combined), `location` as is. `countries` also activates that country's preset, so add `"includeGlobalBoards": false` and pick boards yourself for EURAXESS alone, or use `location` with a country name.

That is 40+ Tier 1 boards across 25+ countries, plus the three global boards, plus `career-site-jobs` (tied to no country: use `includeCareerSites` or `companyUrls`).

### Supported boards

Every source selectable in the `platforms` picker today (113 of them: 110 job boards plus 3 company-website sources: `career-site-jobs` for 40+ ATS systems, `website-job-extractor` and `website-job-extractor-browser` for any other company website). Each links to its standalone Apify actor, which you can also run on its own. RAM is each actor's own `actor.json` ceiling, the value job-feed budgets against (see [RAM and parallelism](#ram-and-parallelism)). Prices are the live pay-per-event rates of each actor; "-" means no priced event.

| Board | Apify actor | Country | RAM (MB) | Pricing per event |
|---|---|:---:|---:|---|
| `alpha-topjobs-ch` | [alpha-topjobs-ch-scraper](https://apify.com/santamaria-automations/alpha-topjobs-ch-scraper) | CH | 128 | start $0.001 |
| `ams-at` | [ams-at-scraper](https://apify.com/santamaria-automations/ams-at-scraper) | AT | 256 | start $0.05, item $0.005 |
| `anofm-ro` | [anofm-ro-scraper](https://apify.com/santamaria-automations/anofm-ro-scraper) | RO | 128 | SERP $0.003, detail $0.005, start $0.001 |
| `arbeit-swiss` | [arbeit-swiss-scraper](https://apify.com/santamaria-automations/arbeit-swiss-scraper) | CH | 512 | start $0.0001, item $0.003 |
| `arbeitsagentur-de` | [arbeitsagentur-de-scraper](https://apify.com/santamaria-automations/arbeitsagentur-de-scraper) | DE | 512 | SERP $0.008, detail $0.02, start $0.05 |
| `arbetsformedlingen-se` | [arbetsformedlingen-se-scraper](https://apify.com/santamaria-automations/arbetsformedlingen-se-scraper) | SE | 256 | SERP $0.003, detail $0.005, start $0.001 |
| `bayt-me` | [bayt-scraper](https://apify.com/santamaria-automations/bayt-scraper) | ME | 128 | SERP $0.003, detail $0.005, company $0.005, start $0.001 |
| `biospace` | [biospace-scraper](https://apify.com/santamaria-automations/biospace-scraper) | US | 128 | SERP $0.003, detail $0.005, company $0.005, start $0.001 |
| `builtin` | [builtin-scraper](https://apify.com/santamaria-automations/builtin-scraper) | REMOTE | 128 | start $0.001, item $0.003 |
| `career-site-jobs` | [career-site-jobs-scraper](https://apify.com/santamaria-automations/career-site-jobs-scraper) | ANY | 128 | start $0.001, career-job-result $0.001 |
| `carriera-ch` | [carriera-ch-scraper](https://apify.com/santamaria-automations/carriera-ch-scraper) | CH | 128 | SERP $0.003, detail $0.005, start $0.001 |
| `charityjob-uk` | [charityjob-uk-scraper](https://apify.com/santamaria-automations/charityjob-uk-scraper) | UK | 128 | SERP $0.0014, detail $0.003, company $0.005, start $0.001 |
| `computrabajo` | [computrabajo-scraper](https://apify.com/santamaria-automations/computrabajo-scraper) | INTL | 128 | start $0.001, item $0.003 |
| `craigslist-jobs` | [craigslist-jobs-scraper](https://apify.com/santamaria-automations/craigslist-jobs-scraper) | US | 128 | SERP $0.003, detail $0.005, start $0.001 |
| `cryptojobslist` | [cryptojobslist-scraper](https://apify.com/santamaria-automations/cryptojobslist-scraper) | REMOTE | 128 | start $0.001, item $0.003 |
| `cwjobs-uk` | [cwjobs-uk-scraper](https://apify.com/santamaria-automations/cwjobs-uk-scraper) | UK | 128 | SERP $0.0012, detail $0.0025, company $0.005, start $0.001 |
| `dcz-gov-ua` | [dcz-gov-ua-scraper](https://apify.com/santamaria-automations/dcz-gov-ua-scraper) | UA | 128 | SERP $0.003, detail $0.005, start $0.001 |
| `dice` | [dice-scraper](https://apify.com/santamaria-automations/dice-scraper) | REMOTE | 128 | start $0.001, item $0.003 |
| `dribbble-jobs` | [dribbble-jobs-scraper](https://apify.com/santamaria-automations/dribbble-jobs-scraper) | US | 128 | SERP $0.003, detail $0.005, start $0.001 |
| `e-fellows` | [e-fellows-scraper](https://apify.com/santamaria-automations/e-fellows-scraper) | REMOTE | 128 | SERP $0.0015, detail $0.003, start $0.001 |
| `efinancialcareers` | [efinancialcareers-scraper](https://apify.com/santamaria-automations/efinancialcareers-scraper) | INTL | 256 | SERP $0.003, detail $0.005, start $0.001 |
| `ejobs-ro` | [ejobs-ro-scraper](https://apify.com/santamaria-automations/ejobs-ro-scraper) | RO | 128 | SERP $0.003, detail $0.005, company $0.002, start $0.001 |
| `escape-the-city` | [escape-the-city-scraper](https://apify.com/santamaria-automations/escape-the-city-scraper) | UK | 128 | SERP $0.003, detail $0.005, company $0.005, start $0.001 |
| `euraxess` | [euraxess-scraper](https://apify.com/santamaria-automations/euraxess-scraper) | INTL | 128 | SERP $0.003, detail $0.005, start $0.001 |
| `finn-no` | [finn-no-scraper](https://apify.com/santamaria-automations/finn-no-scraper) | NO | 256 | SERP $0.003, detail $0.005, start $0.001 |
| `forem-be` | [forem-be-scraper](https://apify.com/santamaria-automations/forem-be-scraper) | BE | 128 | SERP $0.003, detail $0.005, start $0.001 |
| `france-travail` | [france-travail-scraper](https://apify.com/santamaria-automations/france-travail-scraper) | FR | 256 | SERP $0.003, detail $0.005, start $0.001 |
| `ge-ch-emploi` | [ge-ch-emploi-scraper](https://apify.com/santamaria-automations/ge-ch-emploi-scraper) | CH | 128 | SERP $0.003, detail $0.005, start $0.001 |
| `glassdoor` | [glassdoor-scraper](https://apify.com/santamaria-automations/glassdoor-scraper) | INTL | 128 | SERP $0.003, detail $0.002, company $0.005, start $0.001 |
| `guardianjobs-uk` | [guardianjobs-scraper](https://apify.com/santamaria-automations/guardianjobs-scraper) | UK | 128 | SERP $0.003, detail $0.005, company $0.005, start $0.001 |
| `hays-uk` | [hays-scraper](https://apify.com/santamaria-automations/hays-scraper) | UK | 128 | SERP $0.003, detail $0.005, start $0.001 |
| `healthecareers-us` | [healthecareers-scraper](https://apify.com/santamaria-automations/healthecareers-scraper) | US | 128 | SERP $0.003, detail $0.005, company $0.005, start $0.001 |
| `hellowork-fr` | [hellowork-fr-scraper](https://apify.com/santamaria-automations/hellowork-fr-scraper) | FR | 128 | SERP $0.003, detail $0.005, start $0.001 |
| `hh-ru` | [hh-ru-scraper](https://apify.com/santamaria-automations/hh-ru-scraper) | RU | 128 | SERP $0.003, detail $0.005, company $0.005, start $0.001 |
| `higheredjobs` | [higheredjobs-scraper](https://apify.com/santamaria-automations/higheredjobs-scraper) | US | 128 | SERP $0.003, detail $0.005, company $0.005, start $0.001 |
| `ictjobs-ch` | [ictjobs-ch-scraper](https://apify.com/santamaria-automations/ictjobs-ch-scraper) | CH | 128 | start $0.0001, item $0.003 |
| `idealist` | [idealist-scraper](https://apify.com/santamaria-automations/idealist-scraper) | INTL | 128 | start $0.001, item $0.003 |
| `indeed` | [indeed-http-scraper](https://apify.com/santamaria-automations/indeed-http-scraper) | INTL | 256 | SERP $0.003, start $0.001, job-with-description $0.005, company-enriched $0.005 |
| `infojobs-es` | [infojobs-scraper](https://apify.com/santamaria-automations/infojobs-scraper) | ES | 128 | SERP $0.003, detail $0.005, company $0.002, start $0.001 |
| `irishjobs-ie` | [irishjobs-ie-scraper](https://apify.com/santamaria-automations/irishjobs-ie-scraper) | IE | 128 | SERP $0.003, detail $0.005, start $0.001 |
| `jobbank-ca` | [jobbank-ca-scraper](https://apify.com/santamaria-automations/jobbank-ca-scraper) | CA | 128 | SERP $0.003, detail $0.005, company $0.005, start $0.001 |
| `jobbasel-ch` | [jobbasel-ch-scraper](https://apify.com/santamaria-automations/jobbasel-ch-scraper) | CH | 512 | start $0.0001, item $0.003 |
| `jobbern-ch` | [jobbern-ch-scraper](https://apify.com/santamaria-automations/jobbern-ch-scraper) | CH | 512 | start $0.0001, item $0.003 |
| `jobindex-dk` | [jobindex-dk-scraper](https://apify.com/santamaria-automations/jobindex-dk-scraper) | DK | 128 | SERP $0.003, detail $0.005, start $0.001 |
| `jobkorea-kr` | [jobkorea-kr-scraper](https://apify.com/santamaria-automations/jobkorea-kr-scraper) | KR | 128 | SERP $0.003, detail $0.005, start $0.001 |
| `jobmittelland-ch` | [jobmittelland-ch-scraper](https://apify.com/santamaria-automations/jobmittelland-ch-scraper) | CH | 512 | start $0.0001, item $0.003 |
| `jobrapido` | [jobrapido-scraper](https://apify.com/santamaria-automations/jobrapido-scraper) | INTL | 128 | SERP $0.003, detail $0.005, start $0.001 |
| `jobs-ac-uk` | [jobs-ac-uk-scraper](https://apify.com/santamaria-automations/jobs-ac-uk-scraper) | UK | 128 | SERP $0.0016, detail $0.003, company $0.005, start $0.001 |
| `jobs-ch` | [jobs-ch-scraper](https://apify.com/santamaria-automations/jobs-ch-scraper) | CH | 128 | start $0.001 |
| `jobscout24-ch` | [jobscout24-ch-scraper](https://apify.com/santamaria-automations/jobscout24-ch-scraper) | CH | 256 | start $0.0001, item $0.003 |
| `jobserve-uk` | [jobserve-uk-scraper](https://apify.com/santamaria-automations/jobserve-uk-scraper) | UK | 128 | SERP $0.003, detail $0.005, company $0.005, start $0.001 |
| `jobup-ch` | [jobup-ch-scraper](https://apify.com/santamaria-automations/jobup-ch-scraper) | CH | 128 | start $0.001 |
| `jobware-de` | [jobware-de-scraper](https://apify.com/santamaria-automations/jobware-de-scraper) | DE | 512 | SERP $0.0037, detail $0.0088, start $0.005 |
| `jobzueri-ch` | [jobzueri-ch-scraper](https://apify.com/santamaria-automations/jobzueri-ch-scraper) | CH | 512 | start $0.0001, item $0.003 |
| `justjoinit` | [justjoinit-scraper](https://apify.com/santamaria-automations/justjoinit-scraper) | PL | 128 | SERP $0.003, detail $0.005, start $0.001 |
| `kariera-gr` | [kariera-gr-scraper](https://apify.com/santamaria-automations/kariera-gr-scraper) | GR | 128 | SERP $0.003, detail $0.005, start $0.001 |
| `karriere-at` | [karriere-at-scraper](https://apify.com/santamaria-automations/karriere-at-scraper) | AT | 128 | SERP $0.003, detail $0.005, start $0.001 |
| `lavoro-cdt-ch` | [lavoro-cdt-ch-scraper](https://apify.com/santamaria-automations/lavoro-cdt-ch-scraper) | CH | 128 | SERP $0.003, detail $0.005, start $0.001 |
| `levels-fyi-us` | [levels-fyi-scraper](https://apify.com/santamaria-automations/levels-fyi-scraper) | US | 128 | SERP $0.003, detail $0.005, company $0.005, start $0.001 |
| `linkedin` | [linkedin-scraper](https://apify.com/santamaria-automations/linkedin-scraper) | INTL | 128 | start $0.001, item $0.001 |
| `meinestadt-de` | [meinestadt-de-scraper](https://apify.com/santamaria-automations/meinestadt-de-scraper) | DE | 512 | SERP $0.0035, detail $0.0085, start $0.005 |
| `michaelpage-intl` | [michaelpage-scraper](https://apify.com/santamaria-automations/michaelpage-scraper) | INTL | 128 | SERP $0.003, detail $0.005, start $0.001 |
| `milkround-uk` | [milkround-uk-scraper](https://apify.com/santamaria-automations/milkround-uk-scraper) | UK | 128 | SERP $0.003, detail $0.005, company $0.005, start $0.001 |
| `mojposao-hr` | [mojposao-hr-scraper](https://apify.com/santamaria-automations/mojposao-hr-scraper) | HR | 128 | SERP $0.003, detail $0.005, start $0.001 |
| `myjob-ch` | [myjob-ch-scraper](https://apify.com/santamaria-automations/myjob-ch-scraper) | CH | 512 | start $0.0001, item $0.003 |
| `nationale-vacaturebank-nl` | [nationale-vacaturebank-nl-scraper](https://apify.com/santamaria-automations/nationale-vacaturebank-nl-scraper) | NL | 256 | SERP $0.003, start $0.001 |
| `naukri` | [naukri-scraper](https://apify.com/santamaria-automations/naukri-scraper) | IN | 4096 | SERP $0.012, start $0.005 |
| `net-empregos-pt` | [net-empregos-pt-scraper](https://apify.com/santamaria-automations/net-empregos-pt-scraper) | PT | 128 | SERP $0.003, detail $0.005, start $0.001 |
| `nhs-jobs` | [nhs-jobs-scraper](https://apify.com/santamaria-automations/nhs-jobs-scraper) | UK | 128 | SERP $0.003, detail $0.005, start $0.001 |
| `nicejob-de` | [nicejob-de-scraper](https://apify.com/santamaria-automations/nicejob-de-scraper) | DE | 512 | start $0.0001, item $0.003 |
| `nofluffjobs-eu` | [nofluffjobs-eu-scraper](https://apify.com/santamaria-automations/nofluffjobs-eu-scraper) | PL | 128 | SERP $0.003, detail $0.005, company $0.002, start $0.001 |
| `ostjob-ch` | [ostjob-ch-scraper](https://apify.com/santamaria-automations/ostjob-ch-scraper) | CH | 512 | start $0.0001, item $0.003 |
| `praca-gov-pl` | [praca-gov-pl-scraper](https://apify.com/santamaria-automations/praca-gov-pl-scraper) | PL | 128 | SERP $0.003, detail $0.005, start $0.001 |
| `prace-cz` | [prace-cz-scraper](https://apify.com/santamaria-automations/prace-cz-scraper) | CZ | 128 | SERP $0.003, detail $0.005, start $0.001 |
| `pracuj-pl` | [pracuj-pl-scraper](https://apify.com/santamaria-automations/pracuj-pl-scraper) | PL | 128 | SERP $0.003, detail $0.005, start $0.001 |
| `profesia-sk` | [profesia-sk-scraper](https://apify.com/santamaria-automations/profesia-sk-scraper) | SK | 128 | SERP $0.003, detail $0.005, start $0.001 |
| `profession-hu` | [profession-hu-scraper](https://apify.com/santamaria-automations/profession-hu-scraper) | HU | 128 | SERP $0.003, detail $0.005, start $0.001 |
| `prospects-uk` | [prospects-uk-scraper](https://apify.com/santamaria-automations/prospects-uk-scraper) | UK | 128 | SERP $0.003, detail $0.005, company $0.005, start $0.001 |
| `reed-uk` | [reed-uk-scraper](https://apify.com/santamaria-automations/reed-uk-scraper) | UK | 128 | SERP $0.003, detail $0.005, start $0.001 |
| `remoteok` | [remoteok-scraper](https://apify.com/santamaria-automations/remoteok-scraper) | REMOTE | 128 | start $0.001, item $0.003 |
| `remotive` | [remotive-scraper](https://apify.com/santamaria-automations/remotive-scraper) | REMOTE | 128 | start $0.001, item $0.003 |
| `rigzone-us` | [rigzone-us-scraper](https://apify.com/santamaria-automations/rigzone-us-scraper) | US | 128 | SERP $0.003, detail $0.005, company $0.005, start $0.001 |
| `schaffu-ch` | [schaffu-ch-scraper](https://apify.com/santamaria-automations/schaffu-ch-scraper) | CH | 512 | start $0.0001, item $0.003 |
| `seek` | [seek-scraper](https://apify.com/santamaria-automations/seek-scraper) | AU | 128 | start $0.001, item $0.0025 |
| `sepe-es` | [sepe-es-scraper](https://apify.com/santamaria-automations/sepe-es-scraper) | ES | 128 | SERP $0.003, detail $0.005, start $0.001 |
| `snagajob-us` | [snagajob-us-scraper](https://apify.com/santamaria-automations/snagajob-us-scraper) | US | 2048 | SERP $0.012, detail $0.015, company $0.008, start $0.005 |
| `sozjobs-ch` | [sozjobs-ch-scraper](https://apify.com/santamaria-automations/sozjobs-ch-scraper) | CH | 256 | start $0.0001, item $0.003 |
| `stellenanzeigen-de` | [stellenanzeigen-de-scraper](https://apify.com/santamaria-automations/stellenanzeigen-de-scraper) | DE | 512 | SERP $0.0025, detail $0.0075, start $0.005 |
| `stellenmarkt-de` | [stellenmarkt-de-scraper](https://apify.com/santamaria-automations/stellenmarkt-de-scraper) | DE | 128 | SERP $0.003, detail $0.005, start $0.001 |
| `stepstone-at` | [stepstone-at-scraper](https://apify.com/santamaria-automations/stepstone-at-scraper) | AT | 512 | SERP $0.0037, detail $0.008, start $0.001 |
| `stepstone-de` | [stepstone-de-scraper](https://apify.com/santamaria-automations/stepstone-de-scraper) | DE | 128 | start $0.001 |
| `subito-it-jobs` | [subito-it-jobs-scraper](https://apify.com/santamaria-automations/subito-it-jobs-scraper) | IT | 128 | SERP $0.003, company $0.005, start $0.001, phone-reveal-result $0.001 |
| `swissdevjobs-ch` | [swissdevjobs-ch-scraper](https://apify.com/santamaria-automations/swissdevjobs-ch-scraper) | CH | 128 | start $0.0001, item $0.003 |
| `targetjobs-uk` | [targetjobs-uk-scraper](https://apify.com/santamaria-automations/targetjobs-uk-scraper) | UK | 128 | SERP $0.003, detail $0.005, company $0.005, start $0.001 |
| `teaching-vacancies-uk` | [teaching-vacancies-uk-scraper](https://apify.com/santamaria-automations/teaching-vacancies-uk-scraper) | UK | 128 | SERP $0.003, detail $0.005, company $0.005, start $0.001 |
| `tes-uk` | [tes-uk-scraper](https://apify.com/santamaria-automations/tes-uk-scraper) | UK | 128 | SERP $0.004, detail $0.006, company $0.005, start $0.001 |
| `totaljobs-uk` | [totaljobs-scraper](https://apify.com/santamaria-automations/totaljobs-scraper) | UK | 128 | SERP $0.003, detail $0.005, start $0.001 |
| `trudvsem-ru` | [trudvsem-ru-scraper](https://apify.com/santamaria-automations/trudvsem-ru-scraper) | RU | 128 | SERP $0.003, detail $0.005, start $0.001 |
| `usajobs-us` | [usajobs-federal-scraper](https://apify.com/santamaria-automations/usajobs-federal-scraper) | US | 128 | SERP $0.003, detail $0.005, start $0.001 |
| `vdab-be` | [vdab-be-scraper](https://apify.com/santamaria-automations/vdab-be-scraper) | BE | 128 | SERP $0.003, detail $0.005, start $0.001 |
| `wayup-us` | [wayup-us-scraper](https://apify.com/santamaria-automations/wayup-us-scraper) | US | 128 | detail $0.005, start $0.001 |
| `web3career` | [web3career-scraper](https://apify.com/santamaria-automations/web3career-scraper) | REMOTE | 256 | start $0.001, item $0.003 |
| `website-job-extractor` | [website-job-extractor](https://apify.com/santamaria-automations/website-job-extractor) | ANY | 256 | job-result $0.004, company-enriched $0.01, start $0.0001, job-result-managed $0.012 |
| `website-job-extractor-browser` | [website-job-extractor-browser](https://apify.com/santamaria-automations/website-job-extractor-browser) | ANY | 2048 | browser-job-result $0.008, browser-company-enriched $0.05, browser-page-processed $0.005, start $0.0001, browser-job-result-managed $0.024 |
| `welcome-to-the-jungle` | [welcome-to-the-jungle-scraper](https://apify.com/santamaria-automations/welcome-to-the-jungle-scraper) | FR | 128 | SERP $0.001, detail $0.001, start $0.001 |
| `wellfound-us` | [wellfound-jobs-scraper](https://apify.com/santamaria-automations/wellfound-jobs-scraper) | US | 128 | SERP $0.006, detail $0.01, company $0.005, start $0.001 |
| `werk-nl` | [werk-nl-scraper](https://apify.com/santamaria-automations/werk-nl-scraper) | NL | 128 | SERP $0.003, detail $0.005, start $0.001 |
| `westjob-at` | [westjob-at-scraper](https://apify.com/santamaria-automations/westjob-at-scraper) | AT | 512 | start $0.0001, item $0.003 |
| `weworkremotely` | [weworkremotely-scraper](https://apify.com/santamaria-automations/weworkremotely-scraper) | REMOTE | 128 | item $0.003, start $0.001 |
| `work-ua` | [work-ua-scraper](https://apify.com/santamaria-automations/work-ua-scraper) | UA | 2048 | SERP $0.003, detail $0.005, company $0.005, start $0.001 |
| `workatastartup-us` | [workatastartup-scraper](https://apify.com/santamaria-automations/workatastartup-scraper) | US | 128 | SERP $0.003, detail $0.005, company $0.005, start $0.001 |
| `ycombinator` | [ycombinator-scraper](https://apify.com/santamaria-automations/ycombinator-scraper) | INTL | 128 | hn-item-result $0.0005, start $0.001 |
| `zentraljob-ch` | [zentraljob-ch-scraper](https://apify.com/santamaria-automations/zentraljob-ch-scraper) | CH | 512 | start $0.0001, item $0.003 |

Regenerate this table with `python3 actors/jobs/job-feed/scripts/gen_board_table.py`.

#### RAM and parallelism

Your Apify account has one memory limit shared by every run on it, not just those job-feed starts, so job-feed budgets sub-actor RAM instead of ignoring it. It first tries to detect the memory actually available (`GET /v2/users/me/limits`, then `GET /v2/users/me`). If you set `maxTotalMemoryMbytes`, the lower of your value and the detected figure wins. If detection is not possible it uses a flat 4096 MB, which fits every plan including Free. job-feed itself adds 256 MB on top.

Each selected board requests the RAM shown in the table above. Boards that fit start immediately, the rest wait, and as RAM frees up the largest waiting request that fits is admitted first so big browser-tier boards are not starved behind small ones. A board whose own need exceeds the whole budget fails fast with `insufficient memory budget`.

### Coming soon

**Built and launching shortly:** none right now. Every healthy registry platform is already public and selectable (113 of 113).

The `job-platform-actors` session ships an average of 5+ new platforms per week. Highest-priority job platforms currently in the build queue (auto-generated from `docs/build-queue/pipeline.json`, top 5 by score, deduped against the registry, corporate single-employer pages excluded -- those are covered by `career-site-jobs`):

| Rank | Platform | Country | Host | Anti-bot | Notes |
|:---:|---|:---:|---|:---:|---|
| 1 | `simplyhired-us` | 🇺🇸 US | `simplyhired.com` | 3.0 | Indeed-owned aggregator |
| 2 | `teamblind` | 🇺🇸 US | `teamblind.com` | 1.5 | Anonymous tech community + jobs |
| 3 | `kaiser-permanente-jobs` | 🇺🇸 US | `jobs.kp.org` | 3.0 |  |
| 4 | `boss-zhipin-cn` | 🏳️ CN | `zhipin.com` | 3.0 |  |
| 5 | `51job-cn` | 🏳️ CN | `51job.com` | 3.0 |  |

*719 more jobs-platforms queued behind these (76 require Camoufox / browser-tier). Regenerate this table with `python actors/jobs/job-feed/scripts/gen_coming_soon.py`.*

### Limitations

- **Some boards sort by relevance only.** Without a native date sort, `sortBy: "newest"` is a best-effort freshness filter over relevance-ordered results.
- **`remoteco` is parked.** remote.co hides employer data behind a paywall, so it is not selectable (see [Coming soon](#coming-soon)).
- **`wellfound-us` costs more per result** ($0.006 per listing, plus $0.010 with details): every request uses an anti-bot proxy.
- **URL-only platforms need a URL.** `career-site-jobs`, `website-job-extractor`, and `website-job-extractor-browser` do nothing without `companyUrls`, `careerSites`, or `searchUrls`. If your whole selection is made of them and none is supplied (for example `includeCareerSites: true` alone), the run is rejected at input validation: exit code 2, `statusMessage` starting `"FAILED: "`, nothing dispatched or billed.
- **The browser retry has no guaranteed win.** It re-runs a zero-job URL through an extractor roughly 3-5x more expensive. It can still return zero jobs (login wall, geofence). See [Company career pages](#company-career-pages).

### Input reference

The input schema carries a tooltip for every field. This section covers defaults and the gotchas.

#### Platform selection

- **`countries[]`** - ISO codes with a preset (see [Country presets](#country-presets)). Activates each country's Tier 1 boards.
- **`platforms[]`** - hand-picked board IDs. Additive to `countries`. Tier 2 boards run only when named here.
- **`includeGlobalBoards`** - default `true`, see [Country presets](#country-presets).
- **`keywords[]`** - search terms, each forwarded to every selected board. Results are deduplicated across keywords and boards, and `matched_query` on each row records which keyword found it. Aliases `searchQueries`, `search_queries`, `queries` fill `keywords` when it is empty.
- **`location`** - city, region, or state, forwarded in whatever form each board accepts. See [Location and language](#location-and-language).
- **`radiusKm`** - search radius, default 25, max 200, clamped to each board's own maximum.
- **`enforceRadius`** - default `true`. Active only when `location` is set and `radiusKm` > 0. Some boards ignore the radius (`linkedin`, `jobscout24-ch`, `karriere-at` and others), so job-feed drops rows clearly farther than `radiusKm` (plus a 10% or 3 km margin) from the geocoded `location`, before any charge or `onlyNewJobs` bookkeeping, using the row's coordinates or its `location.city`. Rows are KEPT when city or coordinates are missing or unknown to the CH/DE/AT/LI place table, the row's country is outside those four, the location is country-level (`Schweiz`), or the job is remote. A canton or Land (`Vaud`, `Steiermark`) is dropped only when its whole area lies beyond the radius. If the search location cannot be resolved or is outside CH/DE/AT/LI, the filter is skipped. `run_summary.radius_filter` reports `{applied, reason, center, radius_km, dropped, kept_unknown}`.
- **`sortBy`** - `"newest"` (default, best for scheduled scrapes), `"relevance"`, `"salary"`, or `""` (forward nothing, board default order). A board that does not know the value ignores it.
- **`titleFilter`** - comma-separated substrings, case-insensitive OR match on `title`, for example `"sales manager, account executive"`. Matching is by substring inside each word, so `labor` also finds `Chemielaborant`; the words `Collaborator`, `Collaborateur` and `Kollaboration` are excluded for terms shorter than `collabor`/`kollabor`. Ten boards filter natively before detail fetch at no extra cost (`arbeitsagentur-de`, `stepstone-de`, `france-travail`, `pracuj-pl`, `indeed`, `linkedin`, `sepe-es`, `prospects-uk`, `milkround-uk`, `nationale-vacaturebank-nl`); the filter is forwarded to them, and every row from every board is then checked against the same rule here after fetch, counted as `dropped_title_filter` (the part from native boards also as `dropped_title_filter_native_board`).
- **`postedWithinDays`** - fractional days (`0` = off, `0.5` = 12 hours). Boards with a native age filter get it forwarded (rounded up to their unit), for the rest rows outside the window are dropped after normalization, counted as `dropped_stale`.
- **`searchUrls[]`** - direct search URLs for advanced use, routed to the board or ATS that matches the URL.
- **`jobUrls[]`** - single job posting URLs for an alive check plus full data, see [Direct job URLs](#direct-job-urls-joburls).

#### Direct job URLs (`jobUrls`)

Paste single job posting URLs (max 500) to check whether they are still online and to get the full job data. Each URL is routed by host to a board that takes detail URLs; one sub-actor run per board.

```json
{ "jobUrls": ["https://www.seek.com.au/job/91878509", "https://www.idealist.org/en/nonprofit-job/abc123-job-title-org-name-city"] }
```

- Boards supported now: `seek` (incl. JobStreet and JobsDB) and `idealist`. Other URLs are not run and not charged; they are listed in `run_summary.job_urls.unsupported` with `no_detail_support`, `unknown_host` or `invalid_url`. If nothing can be routed the run stops with exit code 1 before any charge.
- `jobUrls` alone runs no keyword search. Combined with `countries`, `platforms` or `searchUrls` it adds the lookups to that run.
- Output: normal job rows, each with `job_status` (`open`, `expired`, `unknown`) and `input_refs` of `{source: "jobUrls", field: "jobUrls", index, url}`. URLs that return no row get a plain HTTP check (404/410 or a redirect to a listing page = `expired`), reported in `run_summary.job_urls.status_only`, never as a row.
- Cost: the normal sub-actor rows, nothing extra.

#### Result caps

Three caps, applied from the inside out:

1. **`maxResultsPerQueryPerPlatform`** - raw rows per keyword per board. Alias `maxResultsPerQuery`. When above 0 it wins over cap 2.
2. **`maxResultsPerPlatform`** - raw rows per board across all keywords, before dedup. Alias `maxResults`. With several keywords and cap 1 at 0, each keyword gets `ceil(cap / keywords)` (3 keywords against 10 gives 4 each, so up to 12 raw rows). 0 = each board's own default.
3. **`maxResultsTotal`** - unique jobs delivered after dedup, across all boards. Once reached, job-feed stops accepting and billing further jobs. Sub-actor calls already running still finish, but their extra rows are dropped and never billed by job-feed. 0 = unlimited.

To avoid paying for rows it would then discard, job-feed also clamps the per-query cap sent to each board to `max(1, floor(maxResultsTotal / (boards * keywords)))`. If a run mixes boards with `companyUrls`, the boards see half of `maxResultsTotal` for this split.

On a `companyUrls` or `careerSites` run, `maxResultsPerPlatform` is a per-extractor budget split across its companies with `max(1, floor(cap / companies))`. Six rows over 2 companies gives 3 each (forwarded as `maxJobsPerCompany` to `career-site-jobs` and `maxResultsPerCompany` to the extractors). With more companies than the cap, every company still gets 1, so the total can run slightly over budget in that one case. Rows beyond a company's share are trimmed, never billed by job-feed, and counted as `dropped_over_cap` (see [Company career pages](#company-career-pages)).

#### Content options

- **`includeJobDetails`** - fetch the full description, salary, and requirements from each detail page. Some boards charge more per detail than per SERP row. Off by default. With `companyUrls` or `careerSites` it also reads each posting's employer page, not only board detail pages (`job-detail-result` event per posting, once priced).
- **`detailCountries`** - ISO-2 codes. With `includeJobDetails`, career-site-jobs reads detail pages only for rows of those countries; other rows are still returned and charged. Invalid entries are ignored with a warning.
- **`maxResultsPerCompany`** - per career site: `-1` = every job (career-site-jobs: up to 5000 per company), a positive number caps each company, `0` keeps the current behaviour (500 per company when no cap is set).
- **`includeContactData`** - fill `contacts[]` (recruiter name, email, phone) where the source has them. Implies `includeJobDetails`. About 28 boards expose contacts.
- **`includeCompanyDetails`** - fetch company profiles (`about`, `industry`, `size_range`, `social_urls`, `active_jobs`, and more). Implies `includeJobDetails`. About 28 boards support it.
- **`includeCompaniesWithoutJobs`** - default `true`, adds free zero-job company rows, see [Company career pages](#company-career-pages).
- **`minRelevanceScore`** - 0-100, default 0 (keep all). Each keyword is scored as the share of its tokens found in the title (case and umlaut folded), and a row scores as its best keyword. 50 drops rows where fewer than half of the best keyword's tokens match, 100 keeps only rows where every token of some keyword matches.
- **`excludeAgencies`** - default `false`. Drops rows whose `company.is_agency` is true (staffing, executive search, job aggregators in the DACH agency registry at high or medium confidence, or a name with Zeitarbeit, Personalvermittlung, Personalberatung and similar) before they are charged or marked seen by `onlyNewJobs`. `licence_only` firms are never dropped. The count is `agencies_excluded` in `run_summary`.
- **`includeReportRowsInDataset`** - default `false`, see [Terminal items in the OUTPUT record](#terminal-items-in-the-output-record).

#### Company career pages

Three sub-actors can read a company career page:

| Actor | Reads | Browser or LLM | Roughly |
|---|---|---|---|
| `career-site-jobs` | Pages on a known job system (Greenhouse, Workday, Lever, Workable, and 35+ more) | No | $0.001/job |
| `website-job-extractor` | Any career page over plain HTTP | LLM, managed by default | $0.012/job managed, plus $0.01/company |
| `website-job-extractor-browser` | Any career page, including JS-only apps | Real browser and LLM | roughly 3-5x the HTTP rate |

`career-site-jobs` is always cheapest when it applies. Inputs:

- **`companyUrls[]`** - one career-page URL per company. A URL matching a known ATS fingerprint goes to `career-site-jobs` directly (regardless of `platforms`, no API key needed), anything else to the extractor. Via the API an item may also be an object `{"url": "...", "country": "DE", "company": "..."}` (`country` and `company` optional; `country` works like on `careerSites`).
- **`careerSites[]`** - companies whose job system you already know, for example from `next_run_input`. Each entry is a plain URL (auto-detected) or `{"url": "...", "ats": "...", "company": "..."}`; optional `website`, `employer` (shared group boards such as `vitrea-gesundheit.onlyfy.jobs`) and `country` (ISO alpha-2 default for rows without a location). When you set `countries` and an entry has no `country` of its own, job-feed sends your countries to career-site-jobs on every company query (one country as `country`, several as `countries`): rows of known other countries are dropped before they are charged, rows with an unresolved location are kept with country null. The same applies to `companyUrls` and directory-sourced companies. A per-URL `country` (on `companyUrls` objects or `careerSites`) also applies to rows read by the website extractor, including handover and fallback rows: rows whose location country is known and different are dropped before they are charged and counted in `dropped_off_country`; rows with an unknown country are kept. Your own `location` is forwarded unchanged. Supported `ats` values are in the input schema. Works on its own.
- **`companyList`** - pick employers from the built-in company directory instead of listing URLs. Filters (all optional, AND across fields, OR within a list): `regions` (`ch-de`, `de`), `industries`, `subIndustries`, `countries`, `headcount` (`1000+`, `250-1000`, `50-250`, `<50`, `unknown`), plus `limit` (default 50, maximum 300, taken in alphabetical order of domain). An empty object, or one that matches nothing, is ignored with a warning when other inputs select platforms; on its own it ends the run before anything is dispatched or billed (`Invalid input: companyList ...`, exit code 1). Rows from these companies carry `input_refs` entries `{source: "companyList", field: "companyList", domain, index}` (`index` is the position in the expanded list). Companies on a job system `career-site-jobs` reads go there, the rest go to the extractor like `companyUrls`; companies you already list yourself are skipped. The directory is an embedded snapshot (dated in `run_summary.company_list.snapshot_date`, currently 2026-10-21) that refreshes with each build. See [Company lists](#company-lists).
- **`includeCareerSites`** - default `false`. Turns on `career-site-jobs` without a matching URL. It has no keyword search: with no `companyUrls`, `careerSites` or ATS-matching `searchUrls` the run is rejected, see [Limitations](#limitations).
- **`includeJobDetails`** (alias `includeFullPostingText`), **`maxFullTextChars`** (default 12000, 500 to 50000), **`fullTextLlmFill`** - for pages read by the extractor, each posting's detail page is read (text incl. PDFs in `description_md`, deadline, salary, contact, workload, languages). `fullTextLlmFill: false` skips the LLM fill call.

`keywords`, `location`, `postedWithinDays` and `sortBy` are forwarded to all three and applied server-side before the per-company cap and before charging. When a company's jobs legitimately do not match, its `per_platform[]` entry carries a `company_notes[]` line and it is not retried in the browser.

**Zero-job company rows.** A company read successfully with no open roles (or none matching) is one dataset row with `_type: "company_no_matching_jobs"`: the company (`name`, `domain`, `url`, `career_page_url`, `ats_detected`), `jobs_on_site` (a number only when the site-wide total is known, else `null`), `jobs_matching: 0`, a `reason` (`no_jobs_found`, `filtered_out_by_filters`, `no_location_match_on_site`), a `note`, the `filters` and `scraped_at`. Never charged, not counted in `unique_jobs`. Turn `includeCompaniesWithoutJobs` off for job rows only, or filter on `_type`.

**Picking HTTP or browser.** For URLs matching no known ATS, naming only `website-job-extractor` in `platforms[]` means HTTP only (a JS-rendered page returns a zero-row reason); only `website-job-extractor-browser` sends every such URL to a real browser; both or neither (default) means HTTP first, then a scoped browser retry for companies flagged JS-rendered or with an unexplained zero. Companies that returned jobs or whose zero your filters explain are never re-sent. The legacy `enableBrowserFallback: true` forces the default mode. The retry gets `max(subactorTimeoutSecs, 1800)` seconds (capped by the run's remaining time minus 90 s); on timeout rows already pushed are kept as `TIMED-OUT`, and the zero-row reason is replaced by the retry's outcome.

##### Company lists

`companyList` reads every matching employer of the built-in directory (about 1,100 Swiss and German employers), so you need no career URLs. Use it for sector sweeps.

```json
{ "companyList": { "regions": ["ch-de"], "industries": ["biotech"], "limit": 50 }, "maxResultsPerCompany": 20 }
```

Output: normal job rows with an `input_refs` entry `{source: "companyList", domain, index}`, and `run_summary.company_list` = `{matched, used, capped_at, snapshot_date}`. Cost: the lookup is free, each company costs the normal sub-actor price.

Details: `limit` default 50, maximum 300; a filter matching nothing is ignored with a warning, or ends the run before billing if it is the only input; the snapshot refreshes with each build.

**Handing a job system to the next run.** Rows read through a known ATS carry `ats_system` and `ats_url`. The OUTPUT record's `next_run_input` moves each company whose job system was found from `companyUrls` to a `careerSites` entry, for example `{"url": "https://kenvue.wd1.myworkdayjobs.com/en-US/kenvue_careers", "ats": "workday", "company": "kenvue.com", "website": "https://www.kenvue.com/careers"}`. It carries only `{companyUrls, careerSites}`: merge them into your input and keep your own limits, otherwise each site is read in full. Example, Chewy (264 roles): about $3.20 via the managed extractor, then about $0.27 per run via `career-site-jobs`.

**When career-site-jobs cannot read a company.** If it ends unreadable (`invalid_query`, `board_not_found`, `unsupported_platform`, `blocked`, `parse_error`, `connection_error`, or a failed sub-run), job-feed falls back to the extractor in the same run; those rows are deduplicated, capped and billed like any extractor row. `run_summary.csj_fallback` reports `{attempted, recovered, rows, companies, skipped_reason}`. Turn off with `csjFallbackToExtractor: false`. The reverse also runs: when the extractor recognises a supported job system and finds 0 jobs, job-feed sends the company to `career-site-jobs` once (`run_summary.wje_handover`; turn off with `sameRunHandover: false`). A `careerSites` entry failing for a structural reason moves back to `next_run_input.companyUrls` (listed in `handover_demoted`); temporary failures keep it. With `handoverSharedBoards` on (default), shared group boards are handed over with the employer found. A bare vendor root names no company (`job-boards.greenhouse.io/`, `career2.successfactors.eu`, `app.jazz.co`, `myworkdayjobs.com`, a Prospective careercenter without its id): it is never sent to `career-site-jobs`, so it is not charged, and it is listed in `companies_not_read` as `vendor_root` with the reason. If the `careerSites` entry carries the company's `website` (or a domain in `company`), that site is read by the extractor instead. A `careerSites` entry without a `url` is ignored with a warning; demo tenants are never handed over. If a sub-actor reports a different job-system list version, `run_summary.manifest_mismatch` lists it. Any other unexplained zero (for example `js_rendered_no_browser`) is listed in `run_summary.companies_not_read`, so no requested company disappears silently.

##### Managed LLM key

Without your own `openrouterApiKey`, job-feed forwards no key into `website-job-extractor` runs. It tags the run with NanoScrape's managed key profile, and the extractor resolves the key server-side and bills at its own managed rate, see that actor's README for current prices and caps. Supplying `openrouterApiKey` (or the `geminiApiKey` / `groqApiKey` fallbacks) switches the run to standard billing on your own account.

#### Location and language

**`location`** is free text and most boards accept it as-is. Boards with a closed list, or no location field, are resolved by job-feed when possible:

- **`swissdevjobs-ch`** takes only its own city names (`"Zurich"`, not `"Zürich"`); job-feed matches case- and accent-insensitively. **`arbeit-swiss`** needs a 2-letter canton code, resolved from any canton or main city. **`sozjobs-ch`** (7 German-Swiss macro-regions, French and Italian cantons intentionally no match), **`alpha-topjobs-ch`** (6 JobCloud region IDs, Ticino none) and **`lavoro-cdt-ch`** (Ticino only: Lugano, Mendrisio, Locarno, Chiasso) use closed lists resolved via the canton. **`healthecareers-us`** needs a full US state name (abbreviations and `"City, ST"` are resolved), **`craigslist-jobs`** regional site slugs for major metros. **`jobs-ch`**, **`ictjobs-ch`** and the three company-site actors take free text. **`ge-ch-emploi`** has no location field and lists only Geneva roles.

When a mapping fails, or the board has no location field, job-feed logs a warning and post-filters that board's rows instead: rows whose own location text does not contain your `location` (or have none) are dropped, never billed, and counted as `dropped_off_location`. Rows flagged remote are kept when they name no country or a matching one. This only triggers when you set `location`, never on top of a successful native filter. The three company-site actors are always post-filtered as a safety net.

**`language`** on each row is inferred. Swiss listings use a canton heuristic (Geneva, Vaud, Fribourg, Neuchatel, Jura, Valais to `fr`, Ticino to `it`, else `de`); otherwise a country-to-language table (`DE`/`AT` to `de`, `FR` to `fr`, `US`/`GB`/`CA`/`AU`/`IE`/`IN`/`PK` to `en`, and about 20 more); otherwise a conservative stopword guess over title and description. With no signal it is `""`, never a default `"de"`.

#### Dedup mode

- **`streaming`** (default) - rows are pushed as each board finishes. The first board to report a `job_id` owns the row, later reports go into `sources_manifest` under `additional_sources_by_job_id`. `sources[]` has length 1. Best for real-time use, fastest first result, and aborts (you pay only for rows already delivered).
- **`batch`** - rows are buffered until all boards finish, then emitted once per `job_id` with every reporting board in `sources[]`. No partial delivery if the run is killed, slower first result, more memory.

Billing is identical: one event per unique `job_id`. See [How job IDs and dedup work](#how-job-ids-and-dedup-work).

#### Only new jobs

Off by default. Set `onlyNewJobs: true` on a scheduled run and job-feed skips jobs an earlier run already delivered: they are not pushed and not charged the `orchestrator-unique-job` fee. Delivered `job_id`s are kept in a named key-value store on your own account (`seenJobsStoreName`, default `job-feed-seen-<hash of your search input>`) and forgotten after `seenJobsTtlDays` (default 30, max 180). The first run delivers everything. `run_summary.only_new` shows `seen_skipped`, `new_pushed`, `store_size` and `ttl_days`.

You still pay the sub-actor fees for repeats, because the platforms run before job-feed knows a job is old. `postedWithinDays` stays your main cost control.

#### Job lifecycle across runs

`trackJobLifecycle: true` on a scheduled run with a fixed input tells you how long each job has been online and which jobs closed since the last run.

```json
{ "countries": ["CH"], "keywords": ["data engineer"], "trackJobLifecycle": true, "seenJobsStoreName": "my-ch-data-jobs" }
```

Output: each row gets `lifecycle: {first_seen_at, last_seen_at, days_online, runs_seen}`; the OUTPUT record gets `closed_jobs` (`job_id`, `title`, `company`, `url`, `first_seen_at`, `last_seen_at`, `days_online`). Cost: free, only small KV storage on your account.

Details:

- Closed jobs are never pushed or charged; at most 500 are listed (`closed_jobs_truncated` for the rest).
- Closed means "not seen in a complete, uncapped run of the same search". Closed detection runs only if the search settings (including `postedWithinDays`) equal the last tracked run's. It is checked per platform: jobs of a platform that failed, was partial or hit `maxResultsTotal`, `maxResultsPerQueryPerPlatform` or `maxResultsPerPlatform` are never closed. When no platform was covered completely, `run_summary.lifecycle.closed_detection` is `skipped_incomplete` and `closed_count` is 0; `closed_detection_sources_skipped` lists the platforms left out. A platform with no cap set by you is judged against the sub-actor's own default result limit, and counts as incomplete if that limit is unknown. Jobs stored by builds before 0.9.148 are never closed by absence. Builds 0.9.144 to 0.9.147 could list still-open jobs as closed on capped runs.
- History sits in the `onlyNewJobs` store (`LIFECYCLE-*` records); unseen entries drop after 120 days, newest 10,000 jobs kept. Deleting the store resets it (and the `onlyNewJobs` list).

#### Company hiring summary

Free, off by default. Set `companySummary: true` to get one summary per company from the jobs of this run: open roles, boards, top countries and cities, roles by function and seniority, newest and oldest posting, median days since posted. It is written to the key-value store only (no dataset rows, no extra charge): the top 200 companies are in the `OUTPUT` record under `company_summary`, up to 5,000 in the `COMPANY_SUMMARY` record. Add `companySummaryStoreName` to keep a history and get `trend` per company. With `onlyNewJobs`, `open_roles` counts only this run's delivered rows.

```json
{ "countries": ["CH"], "keywords": ["QA"], "companySummary": true, "companySummaryStoreName": "my-hiring-trends" }
```

#### Company hiring signals (outputMode companies)

Set `outputMode` to `companies` to get one `company_hiring` row per `companyUrls` entry instead of job rows. It answers "is this company hiring, and for what?" for a list of websites.

```json
{
  "companyUrls": [
    {"url": "https://www.example.com", "company": "Example GmbH", "country": "DE", "ref": "ChIJN1t_tDeuEmsRUsoyG83frY4"}
  ],
  "outputMode": "companies",
  "maxResultsPerCompany": 5,
  "includeGlobalBoards": false
}
```

`ref` is an optional string (up to 200 characters) on a `companyUrls` object. It is echoed unchanged as `ref` on the company row, as `company.ref` on every job row of that URL (jobs mode too), and in `input_refs[].ref`, including rows found through a same-run handover, the career-site-jobs to extractor fallback and the browser retry.

| Field | Meaning |
|---|---|
| `ref` | The `ref` you sent (null if none). |
| `company`, `website` | As sent. |
| `hiring_checked` | false only for `not_read`. |
| `hiring_status` | `hiring`, `no_open_roles` or `not_read`. |
| `hiring_open_roles_count` | The site-wide number of open roles when the reader reports one for this request, else the distinct roles read. null for `not_read`. |
| `hiring_open_roles_count_is_capped` | true only when no site total is known and the roles read equal `maxResultsPerCompany`. |
| `hiring_roles` | Up to `maxResultsPerCompany` titles. |
| `hiring_roles_detail` | Same order and length as `hiring_roles`: `{title, location_raw, city, postal_code, country}` (null for unknown parts). `[]` unless `hiring`. |
| `hiring_evidence` | `hiring`: `ats_board`, `careers_page` or `job_board` (in that precedence). `no_open_roles`: `ats_zero`, `careers_page_empty` or `no_careers_page`. null for `not_read`. |
| `hiring_sources` | ATS names (greenhouse, lever, workday, ...) or `website`. |
| `hiring_jobs_url` | The careers page or ATS board, else null. |
| `hiring_checked_at` | ISO 8601 time. |
| `not_read_reason` | Why the company was not read, else null. A repeated URL keeps its first entry; the repeat gets `not_read` with `duplicate_url`. |

How the mode behaves:

- Only `companyUrls` are read. `countries`, `platforms`, `searchUrls`, `jobUrls`, `careerSites`, `companyList`, `includeCareerSites` and the job boards are ignored with a warning. Use the per-URL `country` to keep only roles in that country; it is applied before any charge.
- `includeCompaniesWithoutJobs` is implied. No job rows and no `company_no_matching_jobs` rows are pushed. `onlyNewJobs` and `trackJobLifecycle` are ignored with a warning. `companySummary` still works.
- Billing is unchanged: the `orchestrator-unique-job` event is charged once per unique role read (at most `maxResultsPerCompany` per company), after the company row has landed. The `company_hiring` row itself is free. `run_summary.companies_mode` reports `companies`, `hiring`, `no_open_roles`, `not_read` and `roles_charged`, which equals the events charged. Sub-actor fees are billed as usual.
- `hiringBrowserFallback` (default `false`) decides whether career sites that need a browser (JS-rendered) are retried. A browser read costs about $0.18 per place versus $0.01-0.03 for normal reads, so by default those places get `not_read` with `not_read_reason` `needs_browser`, `hiring_checked` false and no charge. `run_summary.companies_mode.needs_browser` counts them (a subset of `not_read`). Set it to `true` to retry them in a browser. It is ignored in jobs mode.
- A homepage that was fetched fine but has no careers, jobs or job-system link is `no_open_roles` with `hiring_evidence` `no_careers_page` (count 0, no reason). If the fetch failed or is unknown, it stays `not_read` (`dns_error` means the domain does not resolve). None of these is retried in a browser.
- Websites on directories, social and profile sites (jameda.de, facebook.com, yelp.\*, local.ch, ...) are not read and cost nothing: `not_read` with `not_read_reason` `directory_profile_url`. In jobs mode they are skipped with a warning.
- `not_read_reason` `charge_limit` means the source was not started because your max charge was used up.
- `not_read_reason` `ats_handover_available` means the site uses a job system we could not read in this run. When the same-run handover to the job-system reader succeeds, the company is read and gets `hiring` or `no_open_roles` instead.
- In companies mode `maxResultsPerCompany` is at most 50 (also the number of titles in `hiring_roles`); higher values and `-1` are lowered to 50 with a warning. If two entries resolve to the same board, both list the role and it is charged once. `hiring_jobs_url` falls back to the listing page the roles were read from.
- A company with roles is pushed as soon as its sub-actor result is processed. Companies without roles are pushed when the run's fallback and browser passes are done, because those passes can still find roles.
- With a country, keyword, location, posted-within or title filter active, the reader's site total would count roles you filtered out, so `hiring_open_roles_count` is then the roles read and `hiring_open_roles_count_is_capped` tells whether the cap was hit.

#### Advanced concurrency

- **`maxParallelPlatforms`** - sub-actor runs kept in flight at once (default 8, 1-32). Apify's concurrent-run limit is account-wide, so job-feed lowers this cap to fit it (a free account runs 3 at a time). A `402 concurrent-runs-limit-exceeded` on start is retried with backoff.
- **`perHostConcurrency`** - concurrent runs sharing a host (default 2, max 6).
- **`globalConcurrency`** - legacy in-flight cap (default 0, max 16); the lower of it and `maxParallelPlatforms` wins.
- **`maxTotalMemoryMbytes`** - sub-actor RAM budget, 0 = 4096 MB. See [RAM and parallelism](#ram-and-parallelism).
- **`subactorTimeoutSecs`** - per-board timeout (default 600, 60-3600). The scoped browser retry has its own longer budget.

#### Partial failure and statusMessage

`statusMessage` summarizes the run (`"14/14 platforms succeeded"`, failed boards, the dedup summary, a reason when a healthy board returned nothing, `"+ 2 companies with no matching jobs"`, any `dropped_over_cap`). Partial failure still ends `SUCCEEDED` with every job the working boards returned.

A board job-feed stops waiting on is gracefully aborted and its pushed rows are kept through normal dedup and billing. Its `per_platform[]` entry reports `status: "TIMED-OUT"` with the salvaged `raw_results` and a `reason`; it counts as neither succeeded nor failed. Near the run timeout, boards that cannot usefully start are skipped (`SKIPPED_DEADLINE`) and running boards are stopped shortly before the deadline (`run_summary.stopped_for_deadline`, `deadline_stopped_boards`), see [Run timeout](#run-timeout).

Two cases end `FAILED` with `statusMessage` starting `"FAILED: "`: **exit code 2**, selection rejected at validation (only URL-only platforms and no URL), nothing billed; **exit code 1**, every dispatched platform failed and nothing was delivered. A board that only finished through a salvaged `TIMED-OUT` run does not count as succeeded, but if it delivered and billed a real row the run is not failed on that basis.

#### LLM keys

All optional, used only when a URL is routed to `website-job-extractor` or its browser variant. Leave them blank for managed mode (see [Managed LLM key](#managed-llm-key)). The forced primary model is `deepseek/deepseek-chat-v3-0324`.

- **`openrouterApiKey`** - primary key, standard BYOK billing on your account.
- **`geminiApiKey`** - cross-provider fallback, used only if the whole OpenRouter side fails.
- **`groqApiKey`** - second fallback (Llama-3.3-70b), after Gemini.

### Output reference

Key fields only. The dataset schema (Output tab in the Console) lists every field, and coverage varies per board. Rough coverage: `salary` about 70 boards, `description_text` about 60, `expires_at` about 45, `requirements` about 30, `experience_level` about 25, `skills` about 20, `contacts` about 28, `workload_pct` CH and SE boards, and `technologies` only about 6 (`swissdevjobs-ch`, `workatastartup-us`, `nofluffjobs-eu`, `justjoinit`, `welcome-to-the-jungle`). `location.postal_code` appears only where the board emits one.

#### Job-level fields

| Field | Type | Description |
|---|---|---|
| `job_id` | string | First 16 hex chars of SHA-256 over folded title, company, city. Stable across runs and boards. Same-board collisions get an 8-hex suffix. |
| `job_id_aliases` | string\[] | `job_id` plus the ids of rows merged into this posting when it was pushed. A merged posting keeps the first arriving row's id, so it can return under another `job_id` next run: check `[job_id, ...job_id_aliases]` against what you have seen. Rows merged later are in `sources_manifest.additional_job_id_aliases_by_job_id`. |
| `title` | string | Job title, HTML stripped. |
| `company` | object | See below. |
| `location` | object | `raw`, `city`, `region`, `country` (ISO alpha-2, UK always `GB`), `remote` (onsite, hybrid, remote), `postal_code`. |
| `employment_type` | string | Closed enum: `permanent`, `temporary`, `contract`, `internship`, `apprenticeship`, or `""`. A raw board value that maps to none of these becomes `""`. |
| `workload_pct` | object | `{min, max}` as 0-100, for CH and SE boards. |
| `salary` | object | `{min, max, currency, period, text, source}`, never currency-converted. `source` is `stated` or `hidden_field` (a band for an ad that hides its salary). |
| `posted_date`, `expires_at` | string | ISO8601 posting date and application deadline. |
| `job_status` | string | Canonical posting status: `open`, `expired` or `unknown` (when the board reports none). Mapped from the board's `job_status`, `is_active`, `expired` or `is_expired`. |
| `job_url_status` | string | Career-site-jobs only: `advertisement_off_apply_url` means `job_url` is the apply page; `portal_frontend_off` means it does not open. |
| `description_text`, `description_html`, `description_md` | string | Full description in three forms. Needs `includeJobDetails` on most boards. |
| `requirements`, `skills`, `technologies` | string\[] | Structured lists where the board provides them. |
| `department`, `experience_level`, `education_level`, `industry` | string | Organizational and seniority metadata, board-dependent. |
| `apply_url` | string | Application URL. |
| `contacts` | object\[] | Recruiter contacts (`first_name`, `last_name`, `email`, `phone`, `position`, `raw`, `source_platform`), needs `includeContactData`. `source_platform` is the board a contact came from and survives merging of duplicate rows. |
| `matched_query` | string | The keyword that surfaced the row. If a board decorates it (`accountant in Dubai`), job-feed rewrites it back to your plain keyword. |
| `relevance_score`, `relevance_matched_tokens` | float, string\[] | Share of keyword tokens found in the title (0.0-1.0, `1.0` when no keywords were given), and which ones. |
| `sources` | object\[] | Origin list (`platform`, `url`, `source_id`, `first_seen`, ...). One entry in `streaming` mode, all in `batch`. |
| `first_seen`, `last_seen`, `content_hash` | string | Run timestamps and a hash over mutable fields for cross-run change detection. |
| `lifecycle` | object | Only with `trackJobLifecycle`: `first_seen_at`, `last_seen_at`, `days_online`, `runs_seen` across runs. See [Job lifecycle across runs](#job-lifecycle-across-runs). |
| `language` | string | Inferred listing language, `""` when unknown. |
| `_platform` | string | The board that first reported the row. |
| `application_deadline`, `reference_number`, `is_easy_apply`, `street_address`, `working_hours`, `languages` | mixed | Detail fields from the website extractor with `includeJobDetails`. Omitted when not reported. |
| `updated_at`, `start_date`, `end_date`, `starts_immediately`, `work_forms`, `locations`, `hours_per_week` | mixed | Source dates and work details when a board reports them. `updated_at` is the source's refresh date (`jobbasel-ch`), `posted_date` stays the first publication. `start_date`, `end_date` (ISO), `starts_immediately` and `work_forms` come from `arbeit-swiss`; an empty `work_forms` means none, absent means not reported. `locations` lists every city of a multi-city ad (`stepstone-de`, `stellenanzeigen-de`). `hours_per_week` is `{min, max}` (`stellenanzeigen-de`); the text as written is in `working_hours`. Omitted when not reported. |
| `hiring_organization` | string | The employing entity when it differs from the board's company (for example company `Johnson & Johnson`, `hiring_organization` `Cilag AG`), as reported by `career-site-jobs` or another sub-actor. Omitted when empty. On a shared group board each row keeps its own `company.name`; the group entry you gave stays in `input_refs`. |
| `input_refs` | object\[] | Only on rows that came from a `careerSites` or `companyUrls` entry: which entries, as `{source, index, url, company, employer}` (`source` is `careerSites` or `companyUrls`, `index` is the 0-based position in that input array, `url`, `company` and `employer` are your values verbatim, `null` when not given). An array because the same job found through two entries lists both. Use this, not `company.name`, to attribute rows to your input: `company.name` is the employer's display name (detected names replace your label, boards shared by several brands carry the brand). Omitted on board and keyword rows. |
| `ats_system`, `ats_url`, `ats_tenant`, `career_page_url` | string | Job-system provenance (for example `workday`, `greenhouse`, `phenom`), empty on board rows. `ats_tenant` and `career_page_url` come from the extractors only, and `ats_url` is a best-effort match for `career-site-jobs`. Distinct from `company.ats_detected`, a best-effort guess made for every board. See [Company career pages](#company-career-pages). |

#### Company sub-fields

| Field | Type | Description |
|---|---|---|
| `name` | string | Company name. On career pages: your `careerSites` label, else the page's declared name, else a cleaned brand from the domain (`new.abb.com` becomes `ABB`). Every row of one company, including its zero-job row, uses the same name, and `company.domain` keeps the real domain. |
| `url`, `domain`, `logo_url` | string | Own website, registrable domain, logo. |
| `about`, `industry`, `size_range` | string | Profile text, sector, headcount bucket. |
| `employee_count_from`, `employee_count_to` | integer | Headcount range bounds, `to` null if open-ended. |
| `rating`, `review_count` | float, integer | Employer rating (scale varies) and review count. |
| `founding_year` | integer | Four-digit year. |
| `benefits` | string\[] | Perks list. |
| `phone`, `email`, `headquarters` | string | Company-level contact data. |
| `address` | object | `{street, city, region, postcode, country}`. |
| `profile_url`, `career_page_url` | string | Board's company profile (first reporter only) and the company's own careers page. |
| `ats_detected` | string | ATS recognized from `apply_url`. |
| `social_urls` | object | Links keyed by network. |
| `active_jobs_count`, `active_jobs` | integer, object\[] | Open-role count, and up to 20 `{title, url}` entries. |
| `recruiters` | object\[] | Recruiter roster (dice only): `name`, `title`, `email`, `profile_url`. |
| `is_agency` | boolean | Always present. True when the company is in the DACH agency registry with `high` or `medium` confidence, or its name has an agency keyword (`Zeitarbeit`, `Personalvermittlung`, `Personalberatung`, `Staffing`, `Recruit`). |
| `agency_type` | string, null | `staffing`, `executive_search`, `rpo`, `job_aggregator`, `engineering_services` or `placeholder`; null when unknown. |
| `agency_confidence` | string, null | `high`, `medium`, `licence_only` or `keyword`. `licence_only` firms only hold a staffing licence, often as a side business. |
| `is_agency_signal` | boolean | True for any registry hit, including `licence_only`, or a keyword hit. Use it for scoring, not hiding. |
| `platform_extras` | object | Passthrough of board-specific `company_*` fields. |

#### Terminal items in the OUTPUT record

Every run produces four `_type`-tagged items: `run_summary`, `cost_report`, `relevance_report`, `sources_manifest`. By default they live only in the run's **OUTPUT key-value-store record**, next to a live-updated `subruns[]` manifest (one entry per board: actor, build, `run_id`, `console_url`, `dataset_id`, `status`, timing, memory, charged amount, `charged_event_counts`). If the run had `companyUrls` or `careerSites`, OUTPUT also carries `next_run_input`. Set `includeReportRowsInDataset: true` to also append the four items to the dataset; if you export to CSV or Excel, drop rows with a non-empty `_type` (or keep only rows that have a `job_id`), since they do not share the job schema.

- **`run_summary`** - totals, `dedup_summary`, `completeness`, and `per_platform[]` with `platform_id`, `status`, `raw_results`, `elapsed_ms`, plus when relevant `country` (global-board sub-runs), `reason`, `dropped_over_cap`, and `dropped_off_location`, `agencies_excluded` when `excludeAgencies` is on, and `radius_filter` when `enforceRadius` ran. `companies_no_openings` lists companies whose career page was read and says there are no vacancies (they are not in `companies_not_read`). `llm_failures` (`companies`, `calls`, `entries`, and a `rerun.companyUrls` fragment to paste) lists companies where extraction calls failed, including partial failures (`error_category` "partial"); a company with 0 jobs and failed calls also stays in `companies_not_read` with reason `llm_failed`. Both need a website-job-extractor build that reports `jobs_status`. Also `companies_not_read` (with `companies_not_read_count`), the list of requested `careerSites` and `companyUrls` entries that gave no jobs because they could not be read; also `companies_without_jobs`, `companies_partially_read` (company, url, platform_id, reason, rows, input_refs) lists career sites that returned rows but were cut short by the time budget or a wind down, never also in `companies_not_read`; and on early stops `stopped_for_deadline` and `deadline_stopped_boards`. Company career-site rows now get `location.country` from the location text when it names one ("CH", "CHE Baden", "Schweiz - Burgdorf", "Brazil: Rio de Janeiro"); rows with no location stay empty and are counted in `company_rows_without_location`. Rows from a `companyList` entry use the directory name instead of a host-derived one.
- **`cost_report`** - `total_cost_usd` and a `per_platform[]` breakdown, see [Billing and cost model](#billing-and-cost-model).
- **`relevance_report`** - per-board on-topic rate, a hint to drop boards that return off-topic rows.
- **`sources_manifest`** - cross-board origins for jobs seen on several boards (streaming mode). It also carries `additional_job_id_aliases_by_job_id` (job_id to the ids of rows merged after that row was pushed). In streaming mode it also carries `additional_input_refs_by_job_id` for a job already delivered that a second `careerSites` or `companyUrls` entry found later.

`input_refs` (same `{source, index, url, company, employer}` objects) also appears on every `companies_not_read` entry, no-match row, and `csj_fallback.companies[]` and `wje_handover.companies[]` entry. The OUTPUT record's `next_run_input_refs` (`{companyUrls: [...], careerSites: [...]}`) maps each item of `next_run_input` (same index) to the input entries it descends from; it is kept outside `next_run_input` so that fragment stays pasteable.

### How job IDs and dedup work

`job_id` is the first 16 hex characters of `SHA-256(foldText(title) | foldText(company) | foldText(city))`. A job with no city uses region and country instead, so remote listings do not collide on an empty city. The same job at the same company in the same city hashes identically from any board, today or next month. Upsert on it.

`foldText` normalizes case, whitespace, umlauts, gender suffixes (`(m/w/d)`, `(f/m/x)`, `(a)`, `:in`), trailing workload markers (`", 80-100%"`), and dash variants.

When matching the same job across boards, titles that differ only by gender tags anywhere in the title (`(m/w/d)`, `(all genders)`), inclusive endings (`*in`, `:in`, `/in`, `Innen`, French `(e)`), pensum (`100%` against `80-100%`), a job reference number, `(unbefristet)` or a trailing city are treated as one title, while two explicit pensum ranges that do not overlap (`50%` against `100%`), Senior against Junior, and trainee or apprentice titles stay separate rows; `job_id` and the shown title are unchanged. Cross-board matches are folded into one row. **Same-board** postings with identical folded title, company, and city are never merged: every posting after the first gets an 8-hex suffix from its own `source_id` (`b39d05cf0914e480` and `b39d05cf0914e480-2e000388`). Two distinct USAJOBS "Accountant (Staff)" postings at Fort Belvoir are a real case.

Every run's `run_summary` and `statusMessage` carry a line like

```
12 rows from boards → 11 unique jobs (1 merged: arbeit-swiss ×1)
```

The parenthetical lists boards that contributed merged rows, most first. On a `companyUrls` run it can also show trimmed rows: `29 rows from boards → 12 unique jobs (0 merged, 17 over cap)`.

### Billing and cost model

job-feed bills one event, `orchestrator-unique-job` ($0.0005), per unique job delivered after dedup and `maxResultsTotal`. Rows dropped before delivery (caps, `titleFilter`, `postedWithinDays`, location post-filter, `onlyNewJobs` repeats, zero-job rows) are never billed by job-feed.

Every board job-feed calls is a separate actor with its own pay-per-event rates (SERP scans, detail fetches, contacts, LLM extraction), billed independently to the same Apify account at the same rate as running it standalone. When you set a max charge, it also limits the sub-actors job-feed starts for you: each gets a share of what is left, and a source that would get too little is skipped (`run_summary.charge_budget_exhausted`, `subruns_skipped_for_budget`). A sub-actor may bill its own events for rows job-feed later trims. On `companyUrls` and `careerSites` runs the sub-actor's company fee applies whenever the career site was read successfully, even with no open jobs.

The `cost_report` in the OUTPUT record separates what compute cost from what you were billed:

- **`per_platform[].user_charged_usd`** - the sub-run's usageTotalUsd from Apify (compute cost, not the event bill, on the actor owner's own account), else events times list price capped at its max charge. Re-read 3 times, 10 s apart. `charges_final: false` means the budget ran out and the figure may be a slight undercount.
- **`per_platform[].price_basis`** - `""` normally, `"free_tier"` (tiered event priced at the conservative FREE tier) or `"actor_fallback"` (no pricing info). Either means a conservative estimate. `estimated_cost_usd` is a registry-based fallback; `platform_compute_usd` is the sub-run's CU cost for internal monitoring and not part of your bill.
- **`orchestrator_charged_events`**, **`subactor_cost_total_usd`**, **`orchestrator_cost_usd`** (`unique_jobs x $0.0005`), **`total_cost_usd`** (their sum) and **`cost_per_unique_job_usd`**.
- **`overlap_matrix`** / **`redundancy_hint`** - which boards return the same jobs, and which to drop without losing coverage.

### Rate card

| Event | Trigger | Price |
|---|---|---|
| `orchestrator-unique-job` | Each unique job emitted after dedup and the optional `maxResultsTotal` cap | $0.0005 |

Sub-actor events are billed by the sub-actors on top, see [Supported boards](#supported-boards) and the worked examples in [What it costs](#what-it-costs). A real DE-Munich Software Engineer run over 4 boards (25 SERP rows each, no detail fetch) gave 85 unique jobs, $0.478 sub-actor subtotal plus $0.042 orchestrator fee: $0.522 total, $0.006 per unique job.

### Examples

All inputs are literal and can be pasted as is.

- **Single country**: `{"countries": ["CH"], "keywords": ["software engineer"]}` fires 6 boards, the CH preset (`jobs-ch`, `arbeit-swiss`, `jobscout24-ch`) plus Indeed, Glassdoor and LinkedIn scoped to CH.
- **Several countries**: `{"countries": ["DE", "AT"], "keywords": ["software engineer"], "maxResultsPerPlatform": 5}` fires 14 boards. The cap of 5 splits as `[3, 2]` across the two countries for the global boards (AT first), while ordinary boards get the full 5.
- **Preset plus a board**: `{"countries": ["DE"], "platforms": ["wellfound-us"], "keywords": ["backend engineer"], "includeGlobalBoards": false}`. `platforms` is additive: 5 DE boards plus `wellfound-us`. Without `includeGlobalBoards: false`, 9 in total.
- **Several keywords**: `{"platforms": ["swissdevjobs-ch", "ycombinator"], "keywords": ["react", "engineer"]}`. Each row keeps the keyword that surfaced it in `matched_query`.
- **Company career pages**: `{"companyUrls": ["https://boards.greenhouse.io/stripe", "https://jobs.lever.co/figma", "https://www.amazon.jobs/en/search?base_query=software+engineer"], "keywords": ["backend"]}`. The first two match ATS fingerprints (`career-site-jobs`), the third goes to `website-job-extractor` in managed mode.
- **Location**: `{"countries": ["CH"], "keywords": ["engineer"], "location": "Zürich"}`. `jobs-ch` takes the text, `arbeit-swiss` resolves canton `ZH`, `jobscout24-ch` its own region enum. A board that cannot honor it (for example `ge-ch-emploi`, Geneva only) is post-filtered: mismatches are dropped uncharged and reported as `dropped_off_location`.
- **Newest, last day**: `{"countries": ["UK"], "keywords": ["product manager"], "postedWithinDays": 1, "sortBy": "newest"}`.
- **Empty input** `{}` applies the code defaults `countries: ["DE"]`, `keywords: ["Software Engineer"]`, `maxResultsPerQueryPerPlatform: 10`, `maxResultsTotal: 50` (Apify's automated QA runs `{}`, and a schema `prefill` is only a Console hint). That is 8 boards sharing 50 rows, each capped at 6.
- **Per-company cap**: `{"companyUrls": ["https://boards.greenhouse.io/stripe", "https://jobs.lever.co/figma"], "maxResultsPerPlatform": 6}` gives `floor(6/2)=3` per company. Rows over the cap are dropped, reported as `dropped_over_cap` and never billed by job-feed, though a sub-actor may have billed its own events for them.
- **Tight budget**: `{"countries": ["DE"], "keywords": ["software engineer"], "includeGlobalBoards": false, "maxResultsPerQueryPerPlatform": 5, "maxResultsTotal": 20, "maxTotalMemoryMbytes": 700, "maxParallelPlatforms": 2}`. Boards that do not fit the RAM budget queue, largest fitting request first.

### FAQ

**Why fewer results than my cap?** Caps are upper bounds. A board may have fewer matches, and dedup and `maxResultsTotal` trim further. A `SUCCEEDED` board with `raw_results: 0` always carries a `reason` in its `per_platform[]` entry.

**Why does my company career page return zero jobs?** A URL matching a known ATS went to `career-site-jobs` (check its `reason`). Otherwise it went to `website-job-extractor`, and a JS-only page may need the browser variant, see [Company career pages](#company-career-pages).

**Does a partial failure fail the run?** No. It ends `SUCCEEDED`. Only a total failure, or a selection of URL-only platforms with no URL, ends `FAILED`, see [Partial failure and statusMessage](#partial-failure-and-statusmessage).

**How do I avoid duplicates across runs?** Use `onlyNewJobs: true` ([Only new jobs](#only-new-jobs)), or upsert on the stable `job_id` in your own storage.

**Where are `run_summary` and the other reports?** In the OUTPUT key-value-store record, unless `includeReportRowsInDataset` is on, see [Terminal items in the OUTPUT record](#terminal-items-in-the-output-record).

**Does it work on the Free plan?** Yes. Boards that do not fit the RAM budget queue instead of failing, see [RAM and parallelism](#ram-and-parallelism).

**How long does a run take?** Under a minute for a few lean boards, several minutes for a large preset. On timeout job-feed keeps collected rows, see [Run timeout](#run-timeout).

### Troubleshooting

| Symptom | Likely cause and fix |
|---|---|
| `FAILED`, exit code 2, nothing dispatched | Only URL-only platforms selected with no `companyUrls`, `careerSites` or `searchUrls`. Add a URL or select keyword boards too. |
| `FAILED`, exit code 1 | Every dispatched board failed. Read `statusMessage` for each reason. |
| `career-site-jobs` is `SKIPPED_NO_INPUT` but the run `SUCCEEDED` | Mixed selection without a URL for it. Expected. |
| Fewer rows than expected from a preset | `includeGlobalBoards` off, or a board returned 0 that day. Check `per_platform[].reason`. |
| Extractor returned 0 on a custom page | JS-rendered (`js_rendering_suspected`). Add `website-job-extractor-browser` to `platforms`. |
| Two jobs share a `job_id` prefix | Same-board collision, disambiguated by the `-XXXXXXXX` suffix. Both are real. |
| `location` seems ignored on a board | Closed location list or no field. Look for `location does not match` in the log. |
| `titleFilter` or `postedWithinDays` seems not to apply | Filtered client-side on most boards. Check `dropped_title_filter` and `dropped_stale`. |
| A cap seems ignored | Misspelled field. Look for `WARN input contains unrecognized keys`. |
| `user_charged_usd` is 0 or low on a board that returned jobs | Apify charged-event lag. Check `charges_final`, re-fetch later; `estimated_cost_usd` is the fallback. |
| A board you expected is not in the preset | It is Tier 2 for that country. Add it via `platforms[]`. |

### Regional boards

`includeRegionalBoards` (default `off`) adds the small tier-2 regional boards to a `countries` run. `nearby` adds the healthy boards whose canton contains your `location` (Basel gives Jobbasel, Zurich gives Jobzueri, Lugano gives Carriera and Lavoro CDT). `all` adds every healthy tier-2 board of the selected countries, including the profession boards. If the location cannot be found, or lies outside the selected countries, nothing is added and a warning is returned. The boards added are listed in `run_summary.platform_selection.regional_boards_added`. Boards you name in `platforms` are never run twice.

### Registry

Place data for `enforceRadius` comes from [GeoNames](https://www.geonames.org/) (CC BY 4.0): places of DE and AT with 500+ inhabitants and all populated places of CH and LI, about 940 KB in `registry/places.json`, plus canton and Land bounding boxes in `registry/regions.json` (see `registry/NOTICE-geonames.txt`).

`registry/platforms.json` is the single source of truth. Status values: `healthy` (in country defaults), `degraded` and `unvalidated` (only when named explicitly), `parked` (pulled, never runs), `excluded` (deliberately never selectable). A platform can also carry `release: "coming_soon"`: built and routed, but its Apify actor is not public yet, so it is excluded from the `platforms` enum and country defaults.

To add or remove a board, run from `actors/jobs/job-feed/` (then `apify push`): `scripts/refresh_registry_from_apify.py` (refreshes `subactor_registry.json`), `scripts/sync_platform_enum.py` (regenerates the `platforms` enum), `scripts/gen_coming_soon.py` and `scripts/gen_board_table.py` (regenerate [Coming soon](#coming-soon) and [Supported boards](#supported-boards)).

### Related actors

When a job row carries `company.url` or `company.domain` (emitted by roughly 15 platforms in SERP mode and most platforms in detail mode), you can feed those URLs directly into contact-extraction actors to enrich with decision-maker emails and phone numbers:

- [website-email-scraper](https://console.apify.com/actors/santamaria-automations/website-email-scraper/info/issues) - extracts emails and phone numbers from any website.
- [website-contact-extractor](https://console.apify.com/actors/santamaria-automations/website-contact-extractor/info/issues) - LLM-driven contact extraction with role classification, LinkedIn profiles, and pitch-relevance ranking.
- [website-job-extractor](https://console.apify.com/actors/qzJqttU80OcxkcWZv/info/issues) - the extractor job-feed uses for any career page, also usable standalone.
- [indeed-http-scraper](https://console.apify.com/actors/Ug3CHrzEU3w7w998E/info/issues) - the Indeed board on its own.
- [glassdoor-scraper](https://console.apify.com/actors/hrJPbHgcDqPpF7NaW/info/issues) - the Glassdoor board on its own.

### Run job-feed from code

job-feed is `santamaria-automations/job-feed` (actor ID `zoDyIgISr7yFPyLJd`). Runs can take minutes with several boards, so prefer `start()` plus a webhook over blocking.

```bash
curl -X POST "https://api.apify.com/v2/acts/santamaria-automations~job-feed/runs?token=$APIFY_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"countries":["CH"],"keywords":["Laborant"],"location":"Basel","onlyNewJobs":true}'
```

The response carries `id` and `defaultDatasetId` immediately. Poll `GET /v2/actor-runs/{runId}` until `status` is `SUCCEEDED`, then read `GET /v2/datasets/{defaultDatasetId}/items`. The same actor is also addressable as `POST /v2/acts/zoDyIgISr7yFPyLJd/runs`.

```js
import { ApifyClient } from 'apify-client';
const client = new ApifyClient({ token: process.env.APIFY_TOKEN });
const run = await client.actor('santamaria-automations/job-feed').call({
    countries: ['CH'], keywords: ['Laborant'], location: 'Basel', onlyNewJobs: true,
});
const { items } = await client.dataset(run.defaultDatasetId).listItems();
const output = await client.keyValueStore(run.defaultKeyValueStoreId).getRecord('OUTPUT');
```

```python
from apify_client import ApifyClient
client = ApifyClient(token=os.environ["APIFY_TOKEN"])
run = client.actor("santamaria-automations/job-feed").call(run_input={
    "countries": ["CH"], "keywords": ["Laborant"], "location": "Basel", "onlyNewJobs": True})
items = client.dataset(run["defaultDatasetId"]).list_items().items
output = client.key_value_store(run["defaultKeyValueStoreId"]).get_record("OUTPUT")
```

#### Get notified when a run finishes

In the Console, open https://console.apify.com/actors/zoDyIgISr7yFPyLJd/integrations, add an HTTP webhook on "Run succeeded" (and failed, timed out, aborted if you want them), and point it at `YOUR_WEBHOOK_URL`. The default payload template works; `resource.defaultDatasetId` and `resource.defaultKeyValueStoreId` are what you need to fetch the rows and the `OUTPUT` record. Apify event types are `ACTOR.RUN.CREATED`, `ACTOR.RUN.SUCCEEDED`, `ACTOR.RUN.FAILED`, `ACTOR.RUN.ABORTED`, `ACTOR.RUN.TIMED_OUT` and `ACTOR.RUN.RESURRECTED`. Acknowledge with a 200 immediately, then process.

To attach a webhook to a single run, pass the `webhooks` query parameter on the run-creation URL: a base64-encoded JSON array of `{eventTypes, requestUrl, payloadTemplate}` objects.

#### Run timeout

The default run timeout is 3600 seconds, set under Run options in the Console or with `?timeout=7200` on the run-creation call. A large `countries`/`keywords` combination with `includeJobDetails: true` can legitimately need more. If the timeout is reached anyway, running boards are stopped gracefully with their rows kept (see [Partial failure and statusMessage](#partial-failure-and-statusmessage)), boards not yet started are skipped, and the run still ends `SUCCEEDED` with what it collected.

### API and Apify Console

- **Console:** https://console.apify.com/actors/zoDyIgISr7yFPyLJd/info/issues
- **API reference:** https://console.apify.com/actors/zoDyIgISr7yFPyLJd/info/api

# Changelog

This Actor's version history is a separate document: https://apify.com/santamaria-automations/job-feed/changelog.md

# Actor input Schema

## `countries` (type: `array`):

Select country markets to activate their curated Tier 1 defaults. Supported: DE, AT, CH, UK (GB accepted as an alias for UK), US, CA, AU, BE, CZ, DK, ES, FR, GR, HR, HU, IE, IT, NL, NO, PL, PT, RO, RU, SE, SK, UA. For international aggregators (Indeed, LinkedIn, Jobrapido) or remote boards (Remotive, WWR, etc.) use the 'Platforms' field — they have no country-default set.

## `includeGlobalBoards` (type: `boolean`):

Also search Indeed, Glassdoor and LinkedIn in each selected country. Only applies when countries are set. Global boards you already listed in platforms are not added twice.

## `includeRegionalBoards` (type: `string`):

Off by default. Only applies when countries are set. 'Nearby' also searches the small regional boards (for example Jobbasel, Jobzueri, Ostjob, Carriera) whose canton contains your location. 'All' adds every healthy regional and specialist board of the selected countries. Needs a location that can be found in CH, DE, AT or LI for 'nearby'. Boards you already listed in platforms are not added twice.

## `platforms` (type: `array`):

Explicit platform IDs. ADDITIVE to `countries` (if you set countries=\[DE,AT,CH] AND platforms=\[wellfound-us], you get all DE/AT/CH Tier-1 defaults PLUS wellfound). Degraded / unvalidated platforms only run when named explicitly here. ★T1 marks Tier-1 platforms already covered by their country default set — no need to add them again if you already selected the country. Naming website-job-extractor and/or website-job-extractor-browser here also picks how companyUrls/searchUrls company pages are handled: only website-job-extractor -> HTTP only, no browser retry; only website-job-extractor-browser -> browser only; both, or neither (default) -> HTTP first with an automatic scoped browser retry. See the README's "Company career pages" section.

## `keywords` (type: `array`):

Search terms. Each keyword is forwarded to every selected platform. Results deduplicated across keywords AND platforms.

## `titleFilter` (type: `string`):

Comma-separated list of title substrings, case-insensitive OR match. Only keep jobs whose title contains at least one of the terms. Example: sales manager, account executive. Leave empty to receive all results (no filter). For platforms that natively support title filtering (stepstone-de), the filter is forwarded to the sub-actor directly (applied before detail-fetch, no extra cost). For all other platforms the orchestrator applies it client-side after receiving results.

## `location` (type: `string`):

City, region, or state. Forwarded to each platform in the format it accepts. A handful of platforms declare location as a closed list rather than free text (e.g. swissdevjobs-ch only accepts "Zurich", not "Zürich"); for those, job-feed matches your value case-insensitively and accent-folded against the platform's own list (Zürich -> Zurich, Genève -> Geneva) before forwarding. If nothing matches, that platform's location filter is skipped (not the whole platform) and a warning is logged — you still get results, just unfiltered by location for that one platform.

## `radiusKm` (type: `integer`):

Search radius in km. Silently clamped per platform if it exceeds that platform's max.

## `sortBy` (type: `string`):

Controls the order platforms return results. Forwarded to each sub-actor using the field name it declares (sortBy or sortOrder). Canonical values:
• (empty) — "Platform default": job-feed forwards NO sort field at all; the sub-actor's own default ordering applies. Use this for platforms whose declared sort enum has drifted from job-feed's registry (rare, self-heals via the strip-and-retry guard) or when you simply don't care about order.
• newest (default) — most recently posted first. Best for scheduled scrapes so fresh listings surface immediately.
• relevance — platform’s own relevance ranking for your keywords.
• salary — highest-paying first (only on platforms that support salary sort). Platforms that don’t recognise the value silently ignore it.

## `postedWithinDays` (type: `number`):

Only include listings posted within this window. Accepts fractional values for sub-day precision: 0.042 ≈ 1 hour (use 1.0/24 for exactly 1 hour), 0.5 = 12 hours, 1 = 24 hours, 30 = one month. 0 = no filter. For platforms that support server-side age filtering, the value is forwarded directly (rounded up to the nearest hour or day as the platform requires). For platforms without native age filtering, the orchestrator drops items whose posted_date is older than this window after normalization.

## `includeCareerSites` (type: `boolean`):

Also fetch listings from Greenhouse, Lever, Workday, Ashby, Personio, and 20 other ATS platforms via career-site-jobs-scraper. Requires companyUrls (company career-page URLs) -- this extractor has no keyword-search mode, so keywords alone cannot drive it; add companyUrls or pick job boards via countries/platforms instead. For non-ATS company URLs (handled separately via an LLM extractor), keywords narrow the results already found on those pages rather than searching for them.

## `companyUrls` (type: `array`):

Fetch jobs from these company career pages. One URL per company. URLs on a known job system (Greenhouse, Workday, Lever, ...) go straight to career-site-jobs (cheapest, no browser or LLM). Everything else goes through website-job-extractor and/or website-job-extractor-browser -- which one depends on `platforms`: only website-job-extractor selected there -> HTTP only, no browser retry; only website-job-extractor-browser selected -> browser only; both selected, or neither -- the default -- -> HTTP first, with an automatic scoped browser retry only for the companies HTTP couldn't read. See the README's "Company career pages" section for the full mode table. Already know a company's job system from a previous run's `next_run_input`? Use `careerSites` instead -- it skips this detection step and is cheaper (about $0.001/job vs about $0.012/job through the managed extractor). If career-site-jobs cannot read a company, job-feed falls back to the extractor in the same run (see csjFallbackToExtractor). Via the API, items may also be objects {"url", "country", "company", "ref"} (country is an optional ISO 3166-1 alpha-2 default; company is an optional label; ref is an optional string of up to 200 characters that is echoed unchanged as company.ref on every row of that URL, in input_refs\[].ref and as ref on company_hiring rows).

## `careerSites` (type: `array`):

Company career pages whose job system (ATS) you already know -- sent straight to career-site-jobs, skipping the detection step companyUrls goes through. Each item is either a plain URL (career-site-jobs auto-detects the platform) or {"url": "...", "ats": "...", "company": "..."} naming the ATS and (optionally) the company explicitly. Supported ats values: adp, ashby, avature, bamboohr, brassring, comeet, eightfold, greenhouse, icims, jazzhr, join, lever, oracle_recruiting (or oracle), personio, phenom, pinpoint, recruitee, rippling, smartrecruiters, successfactors_rmk (or successfactors), talentbrew, taleo, teamtailor, workable, workday. An unrecognized or missing ats falls back to website-job-extractor with a warning rather than being dropped. company is an optional label or domain (e.g. "oracle.com") used for the company's display name and url/domain on both job rows and zero-job rows -- set it when the URL's own host would otherwise produce a misleading name (an Oracle Recruiting Cloud tenant host reads as "Eeho" without it). The easiest way to fill this in: run companyUrls once, then copy the `next_run_input.careerSites` value from the OUTPUT record into this field for every later run against the same companies -- career-site-jobs costs about $0.001/job versus roughly $0.012/job (managed) through the extractor; the OUTPUT record now fills in company (and the original website) automatically. If career-site-jobs cannot read an entry, job-feed retries it with the extractor in the same run so no jobs are lost (turn off with csjFallbackToExtractor), and a structurally broken entry is moved back to companyUrls in next_run_input. An entry may also carry an optional website (the company's original site), used only for that fallback. An entry may also carry an optional employer, for a shared group board where several employers list on one URL (for example vitrea-gesundheit.onlyfy.jobs): career-site-jobs then keeps only that employer's jobs, and two entries with the same url but different employers are both read. Optional country (ISO 3166-1 alpha-2, e.g. "CH") sets a default country for career-site-jobs rows whose location cannot be resolved, useful for single-country tenants; rows with a known other country are still dropped.

## `companyList` (type: `object`):

Pick employers from the built-in company directory instead of listing URLs. Object with optional filters: regions (ch-de, de), industries, subIndustries, countries (CH, DE), headcount (1000+, 250-1000, 50-250, <50, unknown) and limit (default 50, max 300). All filters are combined with AND, values inside one list with OR; an empty object, or one matching nothing, is ignored with a warning if other inputs select platforms, else the run ends unbilled. Example: {"regions": \["ch-de"], "industries": \["biotech"], "limit": 50}. Cost: lookup free, normal sub-actor price per company.

## `includeJobDetails` (type: `boolean`):

When ON, each result is enriched with full description, salary, requirements from the platform's detail page. Cost impact varies by platform: arbeitsagentur-de detail is $20/1k (vs $8/1k SERP), meinestadt-de and jobware-de are ~$8.50/1k detail. Flat-rate platforms (ams-at, arbeit-swiss, linkedin) are unaffected. With companyUrls or careerSites, ON also reads each posting's employer page, not only board detail pages: the website extractor opens it, stores the posting text in description_md plus structured fields such as salary, deadline, contact and workload, and bills its job-detail-result event per posting (billed per posting once priced). Leave OFF unless description text is required.

## `detailCountries` (type: `array`):

Optional list of ISO 3166-1 alpha-2 country codes (for example CH, DE). Only used with includeJobDetails. Sent to career-site-jobs, which then reads detail pages only for rows whose country is in the list; other rows are still returned and charged, just without the detail page. Codes are uppercased; entries that are not 2 letters are ignored with a warning. Leave empty to read details for every row (default).

## `maxResultsPerCompany` (type: `integer`):

Per company or career site (companyUrls, careerSites). -1 = every job of each site (career-site-jobs pages the whole list up to its hard limit of 5000 jobs per company; the website extractor up to 10,000). A positive number caps each company at that many jobs and replaces the share derived from maxResultsPerPlatform or maxResultsTotal. 0 or absent keeps the current behaviour: those caps are divided across the companies, and with no caps career-site-jobs stops at its own default of 500 jobs per company.

## `includeContactData` (type: `boolean`):

When ON, populates output field `contacts[]` (per-job recruiter contacts: salutation, first_name, last_name, email, phone, position) and, for dice results, `company.recruiters[]` (company-level recruiter roster). Roughly 28 of our 50 platforms surface per-job contact fields on the detail page. IMPLIES includeJobDetails, since contact info lives on the detail page.

## `enableBrowserFallback` (type: `boolean`):

Deprecated 2026-10-03, kept only so old saved inputs keep working. The browser-retry behavior this used to turn on/off is now selected via `platforms`: name only website-job-extractor for HTTP-only (no browser retry), only website-job-extractor-browser to send company pages straight to a browser, both (or neither -- the new default) for HTTP first with an automatic scoped browser retry. See the README's "Company career pages" section. If set, `true` forces the HTTP-first-with-retry behavior regardless of `platforms`; `false` or omitted is ignored -- the platforms-based rule above decides on its own.

## `includeCompanyDetails` (type: `boolean`):

When ON, opts sub-actors into the second-tier company-profile fetch. Populates rich `company.*` slots when the platform emits them: about, industry, size_range, phone, email, headquarters/address, social_urls (LinkedIn/Facebook/Twitter/Instagram/YouTube/TikTok/Xing/GitHub/Kununu/Glassdoor), active_jobs_count, active_jobs\[], plus a platform_extras passthrough for platform-specific fields (e.g. LinkedIn follower_count, Glassdoor rating). Roughly 28 of our sub-actors accept `includeCompanyDetails`; 5 older ones (dice, google-jobs, etc.) accept `includeCompanyInfo` — both are forwarded. IMPLIES includeJobDetails. Adds one extra fetch per unique company, so cost impact depends on how many distinct employers appear in your results.

## `csjFallbackToExtractor` (type: `boolean`):

When ON (default), a companyUrls/careerSites company that career-site-jobs cannot read (invalid query, board not found, unsupported platform, blocked, parse error, connection error, failed sub-run) is retried once with website-job-extractor in the same run, so its jobs are not lost. career-site-jobs is cheaper (about $0.001/job vs about $0.012/job managed), the fallback only costs extractor rates for companies it actually rescues. Timed-out companies are never retried. Turn OFF to only list such companies in run_summary.companies_not_read.

## `handoverSharedBoards` (type: `boolean`):

When ON (default), the website extractor does not read a shared group board itself (for example a hospital group's onlyfy board that lists several employers). It reports the board and the employer instead, and job-feed reads that employer's jobs with career-site-jobs in the same run. Turn OFF to let the extractor read such boards directly.

## `sameRunHandover` (type: `boolean`):

When ON (default), a company the website extractor recognises as sitting on a job system career-site-jobs can read (and finds 0 jobs on) is handed to career-site-jobs in the same run, so its jobs are not delayed to the next run. Companies already retried by csjFallbackToExtractor are never handed back. Turn OFF to only record the handover in next_run_input.

## `includeFullPostingText` (type: `boolean`):

Alias of includeJobDetails for company pages read by the website extractor: true sends includeJobDetails=true to the extractor (posting text incl. PDF postings in description_md, plus structured detail fields; longer runs, paid job-detail event per job). Sent only when true.

## `maxFullTextChars` (type: `integer`):

Only with includeJobDetails (or includeFullPostingText). Maximum characters of posting text stored in description_md per job (500 to 50000). Longer texts are cut at a paragraph boundary. Sent to the extractor only when set.

## `fullTextLlmFill` (type: `boolean`):

Only with includeJobDetails (or includeFullPostingText). On (default): when a job's location, posting date or similar detail is still missing after the posting text was read, one LLM call fills it. Off: the text is still read but no LLM call is made for it. Sent to the extractor only when set.

## `includeCompaniesWithoutJobs` (type: `boolean`):

When ON (default), companyUrls/careerSites companies whose career site was read successfully but had no jobs matching your filters (or none open at all) still get one dataset row (`_type: company_no_matching_jobs`) -- a useful signal on its own, e.g. a funded company with no postings yet. Never charged: you only pay the company fee for the read, same as always, and these rows never count toward orchestrator_billed_events. Turn OFF for job rows only. See README "Zero-job company rows".

## `outputMode` (type: `string`):

jobs (default): one dataset row per job. companies: one `company_hiring` row per companyUrls entry (hiring_status hiring, no_open_roles or not_read, hiring_open_roles_count, hiring_roles, hiring_sources, hiring_jobs_url) and no job rows. In companies mode only companyUrls are read (other sources are ignored with a warning), includeCompaniesWithoutJobs is implied, onlyNewJobs and trackJobLifecycle are ignored, and the usual per-job event is still charged once per unique role read (up to maxResultsPerCompany per company, at most 50); the company row itself is free. Send `ref` on each companyUrls object to merge rows back to your own key. See the README section "Company hiring signals".

## `hiringBrowserFallback` (type: `boolean`):

Companies mode only. Retry career sites that need a browser (JS-rendered). Off by default because a browser read costs about $0.18 per place in your account versus about $0.01-0.03 for normal reads. When off, those places get hiring_status not_read with not_read_reason needs_browser and nothing is charged. Ignored when outputMode is jobs.

## `hiringAtsViaCareerSites` (type: `boolean`):

Companies mode only. When a company website uses a known job system, read it directly through career-site-jobs instead of the AI extractor (cheaper, exact titles). Off by default while we measure it.

## `dedupMode` (type: `string`):

How cross-platform duplicates are surfaced.

• streaming (default): rows flow to the dataset AS EACH platform finishes. Each row carries the ONE platform that first delivered it (first-arriver wins). Duplicates that arrive later are recorded in the terminal `sources_manifest` item under `additional_sources_by_job_id[job_id]`. Best for real-time consumers, cost-abort semantics (you pay only for delivered rows), and fastest time-to-first-result.

• batch: rows are buffered until EVERY sub-actor completes, then emitted ONCE per unique job with `sources[]` containing every platform that reported it. Cross-source origins live inside each row — no manifest join required. Best for one-shot pipelines that don't want to reassemble the manifest downstream. Tradeoff: no partial delivery on abort, longer time-to-first-result, higher orchestrator memory footprint.

PPE billing is identical in both modes: exactly one event per unique job_id.

## `onlyNewJobs` (type: `boolean`):

Off by default. When on, jobs that an earlier run already delivered are not pushed and not charged the job-feed fee. Job-feed keeps a list of delivered job IDs in a named key-value store on your own account. Repeats still cost you the sub-actor fees, because the platforms run before job-feed knows a job is old, so keep postedWithinDays set as the main cost control. Runs that share a store name share the list.

## `companySummary` (type: `boolean`):

Off by default, no extra cost. When on, job-feed writes a per-company summary (open roles, boards, top countries and cities, roles by function and seniority, posting dates) to the run's key-value store only: top 200 in the OUTPUT record under company_summary, up to 5,000 in the COMPANY_SUMMARY record. No dataset rows are added. Example: true.

## `companySummaryStoreName` (type: `string`):

Only used with Company hiring summary. Name of a key-value store on your account that keeps open roles per company for each run (up to 90 runs), so each company gets a trend with the change since the last run. Empty means no trend. Example: my-hiring-trends.

## `seenJobsStoreName` (type: `string`):

Only used with Only new jobs or Track job lifecycle. Name of the key-value store that remembers delivered job IDs and job history. Leave empty to get job-feed-seen-<hash> derived from your search settings, so the same schedule always reuses the same store. Set it to share one list across several schedules.

## `seenJobsTtlDays` (type: `integer`):

Only used with Only new jobs. A delivered job is skipped for this many days, then delivered again if it is still listed. Between 1 and 180.

## `trackJobLifecycle` (type: `boolean`):

Off by default. Use on scheduled runs with the same input. Each row gets lifecycle {first_seen_at, last_seen_at, days_online, runs_seen} and OUTPUT lists closed_jobs (seen last run, missing from a complete, uncapped run of the same search; capped or partial runs skip closed detection). Free; history is kept in the Seen-jobs store on your account. Example: {"trackJobLifecycle": true, "seenJobsStoreName": "my-ch-data-jobs"}.

## `minRelevanceScore` (type: `integer`):

Drop rows whose title doesn't match your keywords well enough. Each keyword is scored separately (fraction of its tokens, case-insensitive and umlaut-folded, present in the title) and the row's score is that of the best-matching keyword. 0 = keep everything (default, no filtering). 50 = drop rows where fewer than half of the best-matching keyword's tokens are in the title (removes clearly off-topic hits like 'Preventive Medicine Officer' from a Software Engineer query on a healthcare board). 100 = only keep rows where EVERY token of at least one keyword appears. Values are per-cent so leave at 0 unless you want filtering. The unfiltered dataset always gets a per-platform relevance report as a terminal item so you can decide which platforms are worth keeping.

## `excludeAgencies` (type: `boolean`):

Off by default. When on, rows whose company is a staffing or recruiting agency (company.is_agency = true) are dropped before they are pushed or charged the job-feed fee. Agencies come from a DACH registry of Swiss, German and Austrian staffing, executive search and job-aggregator firms (high and medium confidence only), plus a name check for words like Zeitarbeit, Personalvermittlung and Personalberatung. Firms that only hold a staffing licence (agency_confidence = licence_only) are never dropped. The number dropped is reported as agencies_excluded in the run summary (OUTPUT record).

## `enforceRadius` (type: `boolean`):

On by default. Active only when location is set and radiusKm is above 0. Some boards ignore the radius, so rows clearly farther than radiusKm (plus a margin of 10% or 3 km, whichever is larger) from your location are dropped before they are charged. Rows with no city, an unknown city, only a region, canton or country, or remote jobs are always kept. If the location cannot be found the filter is skipped. The result is reported as radius_filter in the run summary (OUTPUT record).

## `searchUrls` (type: `array`):

Alternative to keywords + platforms. Paste search-result URLs from any supported platform; the feed routes each to the correct sub-actor by host. Unknown hosts fall back to website-job-extractor with a notice.

## `jobUrls` (type: `array`):

Single job posting URLs (max 500) for an alive check plus full data. Routed by host to boards with detail-URL support (now: SEEK incl. JobStreet/JobsDB, Idealist); others are listed as unsupported and not charged. Alone it runs no keyword search. Rows carry job_status (open/expired/unknown).

## `maxResultsPerPlatform` (type: `integer`):

How the three result caps interact (evaluated in this order): (1) maxResultsPerQueryPerPlatform limits results PER KEYWORD on a given platform; (2) this field (maxResultsPerPlatform) limits total RAW results per platform across all its keywords, before cross-platform dedup; (3) maxResultsTotal limits the final UNIQUE job count across ALL platforms combined, after dedup — once hit, no further platform results are accepted or billed. All three default to 0 = no job-feed-side cap (each sub-actor's own default applies, typically 50-200). If maxResultsPerQueryPerPlatform is set and this field is 0, the effective per-platform total is derived as maxResultsPerQueryPerPlatform × keyword-count. Alias: `maxResults` is accepted as a synonym for this field when this field is left at 0 (matches the field name several sub-actors use directly).

## `maxResultsPerQueryPerPlatform` (type: `integer`):

Per-keyword result cap forwarded to each sub-actor — the FIRST cap applied (see maxResultsPerPlatform for how all three caps interact). When set to a positive integer, overrides Max results per platform as the per-query cap sent to sub-actors. Use this when you run multiple keywords and want a tighter cap per keyword (e.g. 10 results per keyword x 5 keywords = 50 max per platform). 0 = fall back to maxResultsPerPlatform. Alias: `maxResultsPerQuery` is accepted as a synonym for this field when this field is left at 0 — added after a run sent `maxResultsPerQuery: 3` (not a real field name) and got silently ignored, returning far more results than expected.

## `maxResultsTotal` (type: `integer`):

Cap on unique jobs emitted after cross-platform dedup — the LAST and outermost of the three caps (see maxResultsPerPlatform for how they interact). 0 = unlimited. This is the number the orchestrator event bills on: once this many unique jobs have been delivered, job-feed stops accepting further unique jobs from any platform (already-running sub-actor calls still finish, but their extra results are dropped and never billed). A warning is logged once when the cap is reached.

## `perHostConcurrency` (type: `integer`):

Max concurrent sub-actor runs sharing the same host. Prevents cross-actor rate-limit collisions.

## `globalConcurrency` (type: `integer`):

Legacy name for the same in-flight cap as maxParallelPlatforms. Leave at 0 (default) to let maxParallelPlatforms govern alone. Set this to a positive value only if you want a stricter cap than maxParallelPlatforms for an existing saved input: when both are set, the lower of the two wins. New integrations should use maxParallelPlatforms instead.

## `maxParallelPlatforms` (type: `integer`):

Caps how many sub-actor runs job-feed keeps in flight at once. Remaining platforms queue until a slot frees. Protects against Apify's account-wide concurrent-runs-limit-exceeded error (shared across every actor/run on the account, not just this job-feed run) -- a start that still hits the limit is retried automatically with backoff before the platform is marked failed. Combined with globalConcurrency: the stricter (lower) of the two wins only if you set globalConcurrency to a positive value.

## `maxTotalMemoryMbytes` (type: `integer`):

Total RAM budget the orchestrator is allowed to consume across all concurrent sub-actor runs. Sub-actors that don't fit queue and start as running ones finish, largest queued request first. Leave at 0 to use the 4096 MB default, which fits every Apify plan including Free (8 GB). Set a lower value to keep RAM free for other work on the same account (another job-feed run, an unrelated actor, a scheduled task), or a higher one if your plan has more memory and you want more sub-actors running in parallel. Per-platform RAM needs vary: lean Go HTTP actors ~128-256 MB, detail-mode actors ~512 MB, Camoufox browser actors 1024-4096 MB (see the README's Supported boards table for the exact figure per platform). A platform whose own need exceeds this budget fails fast with `insufficient memory budget` and the run continues without it. If Apify itself rejects a sub-actor start because the account-wide memory limit is in use by other runs, job-feed retries with backoff before giving up on that platform.

## `subactorTimeoutSecs` (type: `integer`):

Per sub-actor timeout. A single slow platform never blocks a fast one — its results are dropped on timeout and flagged in the run summary. 600 when unset. A value above a platform's own registry timeout raises it; a lower value never shortens it.

## `includeReportRowsInDataset` (type: `boolean`):

When true, also pushes the run_summary, cost_report, relevance_report, and sources_manifest items into the dataset as extra rows (this was the default before job-feed 0.9.6x). Off by default: the dataset holds only job rows, and the same report data is always available in the run's OUTPUT key-value-store record regardless of this setting.

## `openrouterApiKey` (type: `string`):

OPTIONAL. When you scrape a non-ATS custom career page (e.g. `amazon.jobs/en/search`, `jobs.apple.com/en-us/search`), the orchestrator routes it to the LLM-driven website-job-extractor{,-browser}, using `deepseek/deepseek-chat-v3-0324` (no free-tier cap, strong multilingual, low hallucination). Leave this blank and website-job-extractor runs in NanoScrape's managed mode, billing its own managed PPE rate with no key needed from you. Supply your own key here and it is forwarded to the extractor instead, which then bills its standard, non-managed rate, while the LLM cost is billed to your OpenRouter account.

## `geminiApiKey` (type: `string`):

OPTIONAL. If provided, wired as a cross-provider fallback behind OpenRouter — fires only if the whole OpenRouter side fails. Free tier caps at 1500 requests/day. Get one at https://ai.google.dev/.

## `groqApiKey` (type: `string`):

OPTIONAL. If provided, wired as a second cross-provider fallback (fires after Gemini). Groq's Llama-3.3-70b via fast inference, no per-day cap on the free tier. Get one at https://console.groq.com/keys.

## Actor input object example

```json
{
  "countries": [
    "CH"
  ],
  "includeGlobalBoards": true,
  "includeRegionalBoards": "off",
  "keywords": [
    "Software Engineer"
  ],
  "titleFilter": "sales manager, account executive",
  "location": "Berlin",
  "radiusKm": 30,
  "sortBy": "newest",
  "postedWithinDays": 7,
  "includeCareerSites": false,
  "includeJobDetails": false,
  "maxResultsPerCompany": 0,
  "includeContactData": false,
  "includeCompanyDetails": false,
  "csjFallbackToExtractor": true,
  "handoverSharedBoards": true,
  "sameRunHandover": true,
  "includeFullPostingText": false,
  "maxFullTextChars": 12000,
  "fullTextLlmFill": true,
  "includeCompaniesWithoutJobs": true,
  "outputMode": "jobs",
  "hiringBrowserFallback": false,
  "hiringAtsViaCareerSites": false,
  "dedupMode": "streaming",
  "onlyNewJobs": false,
  "companySummary": false,
  "seenJobsTtlDays": 30,
  "trackJobLifecycle": false,
  "minRelevanceScore": 0,
  "excludeAgencies": false,
  "enforceRadius": true,
  "jobUrls": [
    "https://www.seek.com.au/job/91878509"
  ],
  "maxResultsPerPlatform": 0,
  "maxResultsPerQueryPerPlatform": 0,
  "maxResultsTotal": 0,
  "perHostConcurrency": 2,
  "globalConcurrency": 0,
  "maxParallelPlatforms": 8,
  "maxTotalMemoryMbytes": 0,
  "includeReportRowsInDataset": false
}
```

# Actor output Schema

## `jobFeed` (type: `string`):

Normalized, deduplicated job listings. Only job rows by default -- the cost report and run summary live in the OUTPUT record below, not here, unless includeReportRowsInDataset=true is set.

## `runManifest` (type: `string`):

Live-updated JSON record: run_summary, cost_report, relevance_report, sources_manifest, and a subruns\[] entry per board (actor, actor_id, build_tag, build_number, run_id, console_url, dataset_id, status, started_at, timing, memory, rows, user_charged_usd). Written once at run start with all boards marked "planned", then again after each board finishes -- inspectable even on a hard-killed or still-running actor. Always carries the four report items, regardless of includeReportRowsInDataset. On the final write, also carries next_run_input ({companyUrls, careerSites}) and next_run_input_note (v0.9.x, 2026-10-03): companies whose job system this run discovered move from companyUrls to careerSites, ready to paste into your next run's input so it reads them directly through career-site-jobs instead of back through the extractor. Both omitted when the run supplied no companyUrls/careerSites at all.

# API

You can run this Actor programmatically using our API. Below are code examples in JavaScript, Python, and CLI, as well as the OpenAPI specification and MCP server setup.

## JavaScript example

```javascript
import { ApifyClient } from 'apify-client';

// Initialize the ApifyClient with your Apify API token
// Replace the '<YOUR_API_TOKEN>' with your token
const client = new ApifyClient({
    token: '<YOUR_API_TOKEN>',
});

// Prepare Actor input
const input = {
    "countries": [
        "CH"
    ],
    "keywords": [
        "Software Engineer"
    ],
    "location": "Zürich",
    "radiusKm": 30,
    "postedWithinDays": 7
};

// Run the Actor and wait for it to finish
const run = await client.actor("santamaria-automations/job-feed").call(input);

// Fetch and print Actor results from the run's dataset (if any)
console.log('Results from dataset');
console.log(`💾 Check your data here: https://console.apify.com/storage/datasets/${run.defaultDatasetId}`);
const { items } = await client.dataset(run.defaultDatasetId).listItems();
items.forEach((item) => {
    console.dir(item);
});

// 📚 Want to learn more 📖? Go to → https://docs.apify.com/api/client/js/docs

```

## Python example

```python
from apify_client import ApifyClient

# Initialize the ApifyClient with your Apify API token
# Replace '<YOUR_API_TOKEN>' with your token.
client = ApifyClient("<YOUR_API_TOKEN>")

# Prepare the Actor input
run_input = {
    "countries": ["CH"],
    "keywords": ["Software Engineer"],
    "location": "Zürich",
    "radiusKm": 30,
    "postedWithinDays": 7,
}

# Run the Actor and wait for it to finish
run = client.actor("santamaria-automations/job-feed").call(run_input=run_input)

# Fetch and print Actor results from the run's dataset (if there are any)
print(f"💾 Check your data here: https://console.apify.com/storage/datasets/{run.default_dataset_id}")
for item in client.dataset(run.default_dataset_id).iterate_items():
    print(item)

# 📚 Want to learn more 📖? Go to → https://docs.apify.com/api/client/python/docs/quick-start

```

## CLI example

```bash
echo '{
  "countries": [
    "CH"
  ],
  "keywords": [
    "Software Engineer"
  ],
  "location": "Zürich",
  "radiusKm": 30,
  "postedWithinDays": 7
}' |
apify call santamaria-automations/job-feed --silent --output-dataset

```

## MCP server setup

```json
{
    "mcpServers": {
        "apify": {
            "type": "http",
            "url": "https://mcp.apify.com/?tools=fetch-actor-details,santamaria-automations/job-feed"
        }
    }
}
```

The hosted server signs you in with OAuth on first connect, so no API token belongs in this config. Clients without OAuth support can send an `Authorization: Bearer <APIFY_API_TOKEN>` header instead, using a token from API & Integrations in Apify Console (https://console.apify.com/settings/integrations).

## OpenAPI specification

Download the OpenAPI definition: https://api.apify.com/v2/actors/zoDyIgISr7yFPyLJd/builds/dpuywVgHEPRC7rjWF/openapi.json
