# Duunitori Jobs Scraper - Finland Job Ads, Apply URL & Y-tunnus (`scrapersdelight/duunitori-jobs-scraper`) Actor

Scrape duunitori.fi, Finland's largest job board (~17,500 live ads), by keyword, city, region, industry, occupation, contract type, language, remote and salary-published filters. Optional detail fetch adds the external apply URL + ATS domain; optional employer fetch adds the Y-tunnus business ID.

- **URL**: https://apify.com/scrapersdelight/duunitori-jobs-scraper.md
- **Developed by:** [Scrapers Delight](https://apify.com/scrapersdelight) (community)
- **Categories:** Jobs, Lead generation, Business
- **Stats:** 3 total users, 2 monthly users, 100.0% runs succeeded, 0 bookmarks
- **User rating**: No ratings yet

## Pricing

from $1.00 / 1,000 per job returneds

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?

Actors are web data automations that power AI and operations. They run on the Apify platform to scrape websites, process data, connect APIs, and automate workflows.
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.
Actors are written with capital "A".

## 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.
The best way to integrate Actors is as follows.

- **AI agents and MCP clients** — the [Apify MCP server](https://docs.apify.com/integrations/mcp.md) at `https://mcp.apify.com` (remote, streamable HTTP, OAuth on first use).
- **Agentic workflows and local Actor development** — [Agent Skills](https://apify.com/.well-known/agent-skills/index.json) with the [Apify CLI](https://docs.apify.com/cli/docs.md): `npm install -g apify-cli`, then `apify login`.
- **JavaScript/TypeScript projects** — the official [JS/TS client](https://docs.apify.com/api/client/js/docs.md): `npm install apify-client`.
- **Python projects** — the official [Python client](https://docs.apify.com/api/client/python/docs.md): `pip install apify-client`.
- **Any other language** — the [REST API](https://docs.apify.com/api/v2.md).

For usage examples, see the [API](#api) section below.

For more details, see Apify documentation as [Markdown index](https://docs.apify.com/llms.txt) and [Markdown full-text](https://docs.apify.com/llms-full.txt).

# README

## 🇫🇮 Duunitori Jobs Scraper — Finland's whole job board, as structured rows

Turn **duunitori.fi**, Finland's largest job board, into a clean dataset: **~17,500 live job ads**,
searchable by keyword, city, region, industry, occupation, contract type, ad language, remote,
published-salary and more — with optional enrichment that adds the **employer's external apply URL
and the ATS vendor behind it**, and the employer's **Y-tunnus (Finnish Business ID)**.

No login, no API key, no CAPTCHA. Pay per row you actually receive.

***

### 🎯 What does Duunitori Jobs Scraper do?

It drives Duunitori's own search, reads every result page, deduplicates by job id, and gives you one
row per job ad. Three levels, each priced separately so you only pay for the depth you use:

| Level | Extra request? | What you get |
|---|---|---|
| **Search results** (always) | no | job id, title, company, location, industry, occupation bucket, ad tier, badge, posted date, logo, ad URL |
| **Job details** (optional) | 1 per job | full description, structured salary (min/max/currency/period), employment type, exact posted timestamp, application deadline, the ad's own location string (usually longer than the card's) — **plus the external apply URL and the ATS domain** |
| **Employer profile** (optional) | 1 per *unique* employer | **Y-tunnus (Finnish Business ID)**, officially registered company name, official *toimiala*, company website, social profiles, company description |

***

### 💡 Why the apply URL and the Y-tunnus matter

**Every other Duunitori scraper on this Store stops at title / company / location / salary text.**
We pulled all four rivals' output schemas live on 2026-09-04: not one returns the external apply URL,
and not one returns a business ID.

- **`applyDomain` names the recruiting software each Finnish employer runs.** From one 60-ad sample:
  `ats.talentadore.com`, `jobilla.talentadore.com`, `emp.jobylon.com`, `recright.com`,
  `korsisaari.solaforce.com`, `steadyenergy.careers.haileyhr.app`, `corehr.hrcloud.hr`,
  `tamk.rekrytointi.com`, `georgfischer.wd103.myworkdayjobs.com`, plus employers' own careers
  domains. That is a technographic signal you would otherwise buy from a data vendor.
- **`employerBusinessId` (Y-tunnus) is the join key** into PRH / YTJ and every Finnish B2B database —
  turnover, employee count, directors, credit rating. It turns a job ad into a company record.

Together they answer questions a job feed alone cannot: *which companies are hiring, which HR
software do they already pay for, and who exactly are they on the business register?*

***

### 📊 Field fill rates — measured, not claimed

One unfiltered sweep of the newest 60 ads on **2026-09-04**, details + employer profiles on:

| Field | Fill | Field | Fill |
|---|---|---|---|
| `jobId`, `title`, `company`, `location` | **100%** | `descriptionText` | **100%** |
| `industry`, `adTier`, `badge`, `url`, `slug` | **100%** | `datePosted` → `postedDate` | **100%** |
| `companyLogoUrl`, `allLocations` | **100%** | `validThrough` → `expiresInDays` | **100%** |
| `employmentType` | **100%** | `occupation` | 98.3% |
| `primaryLocation` | 91.7% | **`applyUrl` / `applyDomain`** | **65.0%** |
| `companyWebsite` | 63.3% | `salaryCurrency` / `salaryUnit` | 30.0% |
| `salaryMin` | 26.7% | `salaryMax` | 30.0% |
| `employerBusinessId` (Y-tunnus) | 31.7% | `employerIndustry`, `employerRegisteredName` | 31.7% |
| `employerSocials` | 30.0% | `employerProfileUrl` | 46.7% |
| `directApply` | 20.0% | `hiringOrganization` | 100% |

An independent re-measurement on a separate 60-row unfiltered sweep landed within 3.3 points on every
one of these (`applyUrl` 63.3%, `companyWebsite` 63.3%, `employerBusinessId` 33.3%, `occupation`
95.0%, `primaryLocation` 90.0%, `salaryCurrency` 28.3%), and put `employerProfileUrl` at 60–68%
across its larger sample.

**Honest reading of the low numbers** (these are properties of Duunitori, not of the scraper):

- **`applyUrl` is 65%** because 21 of 60 employers take applications *inside* Duunitori rather than
  linking out. Those rows carry `applyIsExternal: false`, which is what tells "no external ATS" apart
  from "we failed to find one". A separate 28-ad sample measured 71.4%.
- **`directApply` is 20% and is never `false`.** It is a straight passthrough of the ad's own
  schema.org `directApply` flag, and Duunitori only publishes that flag when it is true — measured
  over 172 rows: `true` on 20%, absent on 80%, literally `false` on none. So **`null` means "the ad
  does not state it", not "false"**; use `applyIsExternal` (derived from the page's own apply
  anchors) when you need a yes/no.
- **`hiringOrganization` is the ad's JSON-LD organisation name**, which is normally the same employer
  as `company`, just lower-cased ("wsp finland" vs "WSP Finland") — 21 distinct values against 21 on
  the same 25 rows. It is kept for structured-data fidelity, not as extra information: use `company`
  for the display name and `employerRegisteredName` for the official registry name.
- **`allLocations` is the ad's own location string**, which is usually longer than the card's
  ("Tampere, Oulu, Rovaniemi" where the card says "Tampere ja 4 muuta") — but it is what the employer
  published, **not a guaranteed expansion** of "ja N muuta". Measured counter-example: an ad whose
  card read "Forssa ja 60 muuta" published `allLocations: "Forssa"`.
- **Salary is ~30%** because only ~2,450 of the ~17,500 live ads publish pay at all. Switch on
  **"Only ads that publish a salary"** and every delivered row carries structured salary — measured
  on a 15-row run: `salaryCurrency` 15/15, `salaryMax` 15/15, `salaryMin` **13/15 (87%)**, because a
  handful of ads publish a single figure rather than a range (e.g. 3,600–4,300 EUR/MONTH, or a flat
  20.80 EUR/HOUR that lands in `salaryMax` only).
- **Y-tunnus is 32%** because only Duunitori's modern employer profiles carry the registry block, and
  only ~47% of ads link to an employer profile at all. Of the profiles that exist, 12 of 21 unique
  employers in that run carried usable data. **You are never charged for a profile that returned
  nothing.**
- `descriptionText` runs 1,748–11,851 characters (median 4,220).

***

### 🔎 Every filter, verified against live result counts

Each filter below was run against the live site on 2026-09-04 and **changed the result count** — none
of them is decorative. Board size that day: **17,596** live ads.

| Input | Live count | Input | Live count |
|---|---|---|---|
| *(no filter)* | 17,596 | `remoteOnly` | 926 |
| `searchQueries: ["developer"]` | 122 | `withSalaryOnly` | 2,452 |
| …`+ searchDescriptions` | **190** | `greatPlaceToWorkOnly` | 79 |
| `municipalities: ["helsinki"]` | 3,429 | `diversityPromiseOnly` | 1,697 |
| `municipalities: ["oulu"]` | 903 | `goodSummerJobOnly` | 5 |
| `regions: ["uusimaa"]` | 6,143 | `ageGroup: students` | 1,074 |
| `regions: ["pirkanmaa"]` | 2,201 | `ageGroup: below_18` | 40 |
| `regions: ["lappi"]` | 1,314 | `employmentType: full_time` | 15,469 |
| `industries: ["talonrakennus"]` | 1,444 | `employmentType: part_time` | 2,956 |
| `industries: ["lakiala"]` | 84 | `contractType: permanent` | 13,569 |
| `occupations: ["kuljettaja"]` | 744 | `contractType: fixed_term` | 4,658 |
| `adLanguage: eng_lang` | 1,778 | `contractType: summer_job` | 89 |
| `adLanguage: swe_lang` | 326 | `adLanguage: fi_lang` | 13,560 |

Filters combine: `industries:["lakiala"] + municipalities:["helsinki"]` → 53 · `industries:["lakiala"] +
searchQueries:["juristi"]` → 23 · `regions:["pirkanmaa"] + employmentType:["part_time"]` → 320.

**Two traps this Actor handles for you, both measured:**

1. **Duunitori keeps only the LAST value of a repeated filter.** Sending
   `filter_work_type=full_time&filter_work_type=part_time` returns 2,956 — the part-time count, not
   the union — and a comma-joined value is ignored entirely (returns the full 17,596). So every
   multi-select here is expanded into **separate searches and merged by job id**. You are never
   billed twice for a job that two of your searches both matched.
2. **The site's `?category=` parameter does not filter anything.** `category=lakiala`,
   `category=rakennusala` and `category=tietotekniikka` all returned the identical 17,537 total. Only
   the `/tyopaikat/ala/<slug>` browse path filters — which is what this Actor uses.

A region/industry/occupation slug Duunitori doesn't know returns **HTTP 200 with zero rows**, which
looks exactly like a real empty search. So slugs are checked against the site's own taxonomy (19
regions, 191 industries, 10 occupations, enumerated live) *before* a request is spent, the closest
matches are named in the log, and if **none** of your slugs are valid the run stops clean rather than
quietly scraping the whole board.

***

### 📅 Freshness, deltas and the recurring-run shape

**5,786 of 17,537 ads were posted in the last 7 days — about 33% weekly churn.** That is what makes a
schedule worth setting up.

- **`postedWithinDays`** — verified: with `2`, 53 of 80 cards were dropped and every delivered row
  came back at 0, 1 or 2 days old.
- **`expiringWithinDays`** — the renewal/re-post trigger a staffing firm calls on. Verified: with `5`,
  9 of 10 rows were filtered and the survivor expired in 3 days.
- **`onlyNewSince`** — incremental mode. Verified across three runs: run 1 returned 7 jobs, run 2 on
  the same key returned **0** ("already seen"), run 3 on a different key returned 7 again. State
  lives in a **named** key-value store, so it survives between runs.

> ℹ️ Duunitori has **no date sort and no date filter** of its own — `order_by=date`, `-date`,
> `published` and `search_rank` all return byte-identical pages — so these three are applied by this
> Actor after reading each ad's date. **Rows they remove are never charged.**

***

### 🧾 Deduplication — and why it changes your bill

Duunitori's index churns ~33% a week, so offset pagination re-shows some ads at depth. Measured on
**contiguous** page bands: `sivu=1..12` → 240 fetched / 238 unique (**0.8%**); `sivu=600..619` → 400
fetched / 362 unique (**9.5%**).

The Actor deduplicates by `jobId` **across the whole run, before charging**. Proven offline: three
identical searches for *kehittäjä* read 21 cards, dropped 14 duplicates, and delivered and billed
exactly **7** unique jobs. Turn `deduplicateByJobId` off and the same input delivers 21.

For the same reason this page does **not** promise "all 17,537 jobs". It promises **every job
matching your search, deduplicated.**

***

### 🚦 Reliability

duunitori.fi sits behind Cloudflare. Measured escalation ladder on 2026-09-04:

| Transport | Result |
|---|---|
| Plain HTTP, no proxy | 403 — 6,023-byte "Just a moment" challenge |
| HTTP + datacenter proxy | 403 — challenge |
| **HTTP + Apify RESIDENTIAL, country FI** | **200 — 209,303 bytes of real job cards** ✅ |
| HTTP + RESIDENTIAL, country US / GB | 403 — challenge |

No browser is needed and none is used (512 MB, plain HTTP + HTML parsing — which is why this Actor is
cheap to run). **FI, SE, NO, DK, DE and EE exits all clear; GB and US are challenged**, so the Actor
pins the residential exit to **FI** unless you choose a country yourself. Leave the proxy on Apify
Proxy → RESIDENTIAL.

Across **323 requests in 36 validation runs**: **0 Cloudflare challenges**, 1 transient proxy hiccup
(`590 UPSTREAM504`), retried on a fresh session and recovered — **99.7% first-attempt, 100%
eventual**. An earlier 158-call load test measured 98.7%.

Three behaviours that keep runs honest:

- **A 404 past the last result page is the END of results, not an error.** The last page is exactly
  `ceil(total/20)`; the Actor computes it and also follows the site's own `<link rel="next">`, so it
  doesn't even fetch pages that cannot exist. Verified: a 122-result search read exactly 7 pages in 7
  requests with 0 duplicates.
- **A retry always mints a new proxy session** — the residential pool's transient failure throws
  before any HTTP response exists, so a normal HTTP retry cannot see it.
- **The run FAILS LOUDLY when the data is wrong**, and exits **clean** when a search is legitimately
  empty. Run it with no proxy and it fails in seconds with an actionable message instead of returning
  an empty dataset that looks like "no jobs found".

***

### 💰 Pricing

| Event | Price | When it fires |
|---|---|---|
| **Per job returned** | **$1.00 / 1,000 rows** ($0.001) | every deduplicated job delivered to your dataset |
| **Per job detail fetched** | $1.50 / 1,000 ($0.0015) | only with *Fetch job details* on, and only for rows you receive |
| **Per employer profile fetched** | $2.00 / 1,000 ($0.002) | only with *Fetch employer profile* on, and only for rows that came back with data |

There is **no start fee** and **no platform-usage surcharge**. Rows are billed with a budget-aware
push, so if you set a max charge you keep exactly the rows you paid for — never more delivered than
billed, never more billed than delivered.

**What things actually cost** (from the measured corpus of 17,537 live ads):

| Job | Rows | Cost |
|---|---|---|
| One-off snapshot of the whole board | 17,537 | **$17.54** |
| Same, with full detail on every ad | 17,537 | **$43.84** |
| Weekly delta (~5,786 new ads) | 5,786 | **$5.79/week ≈ $301/yr** |
| Every English-language ad, enriched | 1,778 | **$4.45** |
| Every ad that publishes a salary, enriched | 2,452 | **$6.13** |
| All 84 legal-sector ads, fully enriched | 84 | **$0.38** |

The cheapest existing Duunitori scraper on this Store charges **$1.20 per 1,000** and offers no
detail or employer tier at all.

***

### 🧑‍💼 Who buys this

- **Finnish HR-tech and recruitment-marketing vendors** (Jobilla, Talented, Talentadore resellers) —
  prospect on `applyDomain`: you can see exactly which ATS every hiring employer already runs.
- **Staffing and RPO firms** — `expiringWithinDays` surfaces ads about to lapse; that is the call.
- **Sales-intelligence and lead-gen agencies** — `employerBusinessId` joins each hiring company
  straight into PRH/YTJ, Vainu or Fonecta.
- **Compensation and labour-market analysts** — `withSalaryOnly` plus structured min/max/period.
- **Job aggregators and AI job agents** — a full, deduplicated, incremental Finnish feed.

***

### 📤 Output sample

```json
{
  "jobId": "20479402",
  "title": "WSP:llä avoinna useita rooleja rakennuttamisessa (Tampere, Oulu, Rovaniemi, Vaasa tai Seinäjoki)",
  "company": "WSP Finland",
  "hiringOrganization": "wsp finland",
  "location": "Tampere ja 4 muuta",
  "allLocations": "Tampere, Oulu, Rovaniemi",
  "primaryLocation": "Tampere",
  "countryCode": "FI",
  "industry": "rakennusala",
  "occupation": "projektijohtaja",
  "employmentType": "FULL_TIME",
  "postedRaw": "Julkaistu 12.8.",
  "postedDate": "2026-08-12",
  "postedDaysAgo": 23,
  "validThrough": "2026-09-09T20:59:00+00:00",
  "expiresInDays": 6,
  "applyUrl": "https://rekry.wsp.com/jobs?split_view=true&department=Rakennuttaminen",
  "applyDomain": "rekry.wsp.com",
  "applyIsExternal": true,
  "directApply": null,
  "url": "https://duunitori.fi/tyopaikat/tyo/wsp-finland-wsplla-avoinna-useita-rooleja-rakennuttamisessa-tampere-oulu-tai-rovaniemi-sdsuu-20479402",
  "companyLogoUrl": "https://duunitori.imgix.net/media/images/logos/WSP_logo.png?auto=format&w=59",
  "companyWebsite": "https://www.wsp.com/fi-FI",
  "adTier": "Ultrakampanja",
  "badge": "Katso",
  "salaryMin": null, "salaryMax": null, "salaryCurrency": null, "salaryUnit": null,
  "employerBusinessId": "0875416-5",
  "employerRegisteredName": "WSP Finland",
  "employerIndustry": "Yhdyskuntasuunnittelu",
  "employerSocials": [
    "https://www.facebook.com/WSPglobal/",
    "https://www.instagram.com/lifeatwspfinland/",
    "https://www.linkedin.com/company/1483604/"
  ],
  "employerProfileUrl": "https://duunitori.fi/yritys/wsp-finland",
  "descriptionText": "Rakennuttamisen palvelumme kasvavat – tule mukaan rakentamaan …",
  "detailFetched": true,
  "employerProfileFetched": true,
  "scrapedAt": "2026-09-04T06:21:00.690Z"
}
```

Three ready-made dataset views ship with it: **Overview**, **Employer leads** (business ID, website,
ATS vendor, apply link) and **Published salaries**.

#### Row shape: `outputFormat`

| Value | Shape | Keys per row |
|---|---|---|
| `flat` *(default)* | `salaryMin`, `salaryMax`, `salaryCurrency`, `salaryUnit` are top-level columns — what a CSV/spreadsheet export wants | **42** |
| `nested` | those four move into a single `salary: { min, max, currency, unit }` object | **39** |

`nested` groups **only** the salary fields. Everything else stays flat — including the employer block
(`employerBusinessId`, `employerRegisteredName`, `employerIndustry`, `employerSocials`,
`employerDescription`, `employerProfileUrl`) and the apply block. That asymmetry is deliberate (the
salary quartet is the only group a JSON consumer usually wants as one object), but it is worth
knowing before you write a parser against it. The **Published salaries** dataset view reads both
shapes; whichever you run, the other shape's four salary columns come back empty.

**Rows are billed identically in both shapes** — `outputFormat` changes presentation only.

#### Running with no input at all

The Console form prefills *Fetch job details* **on**. An API or scheduled call that sends an empty
input `{}` gets the schema default, which is **off** — you receive list-level rows only, and 24 of
the 42 columns (description, salary, apply URL/ATS domain, deadline, employment type, occupation,
company website and the whole employer block) come back `null` on every row, still billed at
$0.001/row. The Actor logs that list in a warning at the start of any such run. Send
`"fetchJobDetails": true` if you want the enriched row.

***

### ❓ FAQ

**Do I need a Duunitori account or an API key?**
No. Only public job-ad pages are read.

**Why must I use a residential proxy?**
Cloudflare fronts the site and only serves Nordic residential exits. Measured: no proxy and
datacenter proxies both get a challenge; RESIDENTIAL pinned to FI returns real data. Leave the proxy
field on its default and it is handled for you.

**Can I paste a search URL straight from my browser?**
Yes — *Start URLs*. Every filter in the URL is kept exactly as you set it, including facets this form
doesn't expose. Any `?sivu=` page number is stripped; the Actor paginates the whole result set itself.

**Can I get all 17,500 jobs in one run?**
Set *Max jobs* to 0 and leave the filters empty. Note the honest caveat: Duunitori's index churns
\~33% a week and pages shift under offset pagination, so what you get is *every job your search
matched, deduplicated* — not a guaranteed frozen census of the board at one instant.

**Does it scrape salaries?**
It returns the ad's own structured salary when the employer published one — about 30% of ads. Switch
on *Only ads that publish a salary* and every delivered row carries structured salary (measured
15/15 for currency and max; `salaryMin` was 13/15, because some ads publish one figure, not a range).

**What is a Y-tunnus?**
The Finnish Business ID (e.g. `2854570-7`) — the national company registry number. It is the join key
into PRH/YTJ and Finnish B2B data providers.

**Why is the apply URL missing on some rows?**
Because those employers take applications on Duunitori itself. `applyIsExternal: false` tells you so
explicitly, rather than leaving you guessing.

**Can I run it on a schedule and only get new jobs?**
Yes — switch on *Only jobs not seen in a previous run*. Use a different *state key* per saved search.

**Will it search in English?**
Yes. Duunitori's search works in both languages (`developer` 122 hits, `kehittäjä` 7), and
`adLanguage: eng_lang` restricts to the 1,778 ads written in English.

**How fast is it?**
The site answers in ~0.9–1.2 s. A 60-job run with full detail and employer profiles took 84 requests.
Concurrency defaults to 2 — there is nothing to gain from hammering a site that answers this quickly.

**What happens if my filters match nothing?**
The run ends **successfully** with a status message explaining it, and you are charged nothing. Only
a genuinely broken run (no page readable, or a page that reports results but yields no parseable
cards) is failed.

***

### ⚖️ Legal & fair use

- **robots.txt.** `https://duunitori.fi/robots.txt` reads, verbatim:

  ```
  User-agent: *
  Disallow: /
  ```

  with named search-engine crawlers allowlisted below it. This Actor reads only **public job-ad
  pages** that Duunitori serves to any visitor. It does not log in, does not create an account, does
  not solve or bypass a CAPTCHA or any other anti-abuse control, and does not access anything behind
  authentication. Whether your use is consistent with Duunitori's Terms of Service is **your call as
  the operator** — read them before you run this at scale.
- **Personal data (GDPR).** Job ads are published in Finland, in the EU. Contact details, recruiter
  names and any personal data in an ad are personal data under the GDPR, and you are the controller
  for whatever you do with them. Have a lawful basis, honour erasure requests, and don't use this to
  build a database of individuals.
- **Rate.** Requests are sequential by default and concurrency is capped at 10. Please don't raise it
  beyond what your use actually needs.
- Trademarks and content belong to Duunitori Oy and the advertising employers. This Actor is not
  affiliated with, endorsed by, or connected to Duunitori.

***

*All numbers on this page were measured live on 2026-09-04 (board size 17,596 ads; 60-ad enrichment
sample; 323 requests across 36 validation runs). None of them are estimates.*

# Actor input Schema

## `startUrls` (type: `array`):

Paste Duunitori search URLs straight from your browser — every filter in the URL is kept exactly as you set it, including ones this form does not expose. Use this to drive the Actor from a spreadsheet of saved searches. Any ?sivu= page number is stripped; the Actor paginates the whole result set itself. Only duunitori.fi /tyopaikat URLs are accepted.

## `searchQueries` (type: `array`):

Free-text keywords, one per line — the site's own 'haku' box. Works in Finnish AND English (measured: 'developer' 122 hits, 'kehittäjä' 7, so both are worth listing). Each keyword is crawled as its own search and the results are merged and deduplicated by job id, so you are never billed twice for a job two keywords both matched. Leave empty to sweep every live ad.

## `searchDescriptions` (type: `boolean`):

Match your keywords against the body of the ad, not just the job title (Duunitori's 'search\_also\_descr'). Measured: 'developer' returns 122 hits on titles alone and 190 with ad text included — good for finding a skill or tool named deep inside a posting.

## `municipalities` (type: `array`):

Finnish city names as Duunitori spells them, one per line — helsinki, vantaa, turku, oulu, jyväskylä, kuopio. Verified live: helsinki 3,429 ads, vantaa 1,157, turku 1,076, oulu 903, jyväskylä 567, kuopio 457. A name Duunitori does not know returns zero rows rather than an error, so the run will tell you in the log instead of silently handing back the unfiltered board.

## `regions` (type: `array`):

Official Finnish regions. Measured sizes: uusimaa 6,143 ads, pirkanmaa 2,201, lappi 1,314. Note these are browse PATHS on Duunitori, so at most one region applies per request — pick several and each is crawled separately and merged.

## `industries` (type: `array`):

Industry slugs from https://duunitori.fi/tyopaikat/selaa/alat, one per line. Examples with live counts: talonrakennus 1,444 · tehdas-ja-tuotantotyontekijat 1,334 · ohjelmointi-ja-ohjelmistokehitys 618 · hoitajat 684 · laakarit 478 · lakiala 84. You can also paste the full browse URL. Each industry is crawled as its own search and merged by job id.

## `occupations` (type: `array`):

Duunitori's ten cross-industry occupation buckets. Measured: kuljettaja (driver) 744 ads.

## `employmentType` (type: `array`):

Full-time (15,469 ads) or part-time (2,956). Pick both to cover everything that declares a type.

## `contractType` (type: `array`):

Permanent (13,569 ads), fixed-term (4,658) or summer job (89).

## `adLanguage` (type: `array`):

Language the ad itself is written in: Finnish (13,560), English (1,778) or Swedish (326). English-language ads are the ones an international recruiter can act on without translation.

## `remoteOnly` (type: `boolean`):

Only ads flagged as remote-friendly (926 live).

## `withSalaryOnly` (type: `boolean`):

Only ads that state pay (2,452 live). This is the slice a compensation-data or salary-benchmarking buyer wants — combine it with 'Fetch job details' to get the structured min/max/currency out of the ad's own structured data.

## `greatPlaceToWorkOnly` (type: `boolean`):

Only ads from employers carrying Duunitori's Great Place to Work badge (79 live). A small, high-signal list of employers who pay for employer-brand certification.

## `diversityPromiseOnly` (type: `boolean`):

Only ads from employers who signed Duunitori's diversity commitment (1,697 live).

## `goodSummerJobOnly` (type: `boolean`):

Duunitori's vetted summer-job programme (5 live off-season — this is a seasonal filter and will be near-empty outside the spring hiring window).

## `ageGroup` (type: `string`):

Ads explicitly open to younger applicants: 15–16-year-olds (6 live), under-18s (40) or students (1,074).

## `postedWithinDays` (type: `integer`):

Keep only ads posted within this many days. Read from the card's 'Julkaistu 21.8.' text, and re-checked against the exact posted timestamp when 'Fetch job details' is on. 0 = no limit.

## `expiringWithinDays` (type: `integer`):

Keep only ads whose application deadline falls within this many days — the re-post/renewal trigger a staffing firm wants to call on. Requires the ad's expiry date, so switching this on turns 'Fetch job details' on automatically and those rows are billed the detail event. 0 = off.

## `includeExpiredOrClosed` (type: `boolean`):

Duunitori sometimes still lists an ad after its application deadline has passed. Leave on to keep them (useful for market-history analysis); switch off to keep only ads still open. Switching it OFF needs the expiry date, so it turns 'Fetch job details' on automatically.

## `fetchJobDetails` (type: `boolean`):

One extra request per job for the full ad: description, structured salary (min/max/currency/period), employment type, exact posted date, application deadline, the full expanded location list — and the EXTERNAL APPLY URL plus the ATS domain behind it (teamtailor.com, ats.talentadore.com, haileyhr.app, …), which tells you which recruiting software each employer runs. Measured over 28 varied ads: description/date/deadline/type 100%, external apply URL 71.4% (the other 28.6% take applications on Duunitori itself), published salary 32.1%. Billed as 'job-detail-enriched'.

## `fetchEmployerProfile` (type: `boolean`):

One extra request per UNIQUE employer (cached, so twenty ads from one staffing firm cost one fetch) for the company page: the Y-TUNNUS — the Finnish Business ID that joins this row to PRH/YTJ and every Finnish B2B database — plus the officially registered company name, the official toimiala, the employer's own website and social profiles, and their company description. Measured on 11 employer pages: 6 carried the registry block (Y-tunnus + official name + toimiala), 4 carried a website; older-style profiles have none. Needs 'Fetch job details' (the employer link lives on the ad page), so it turns that on automatically. Billed as 'employer-profile-enriched'.

## `maxItems` (type: `integer`):

Hard stop on the number of jobs delivered AND billed. Counted after deduplication and after every filter, so you pay for exactly this many usable rows at most. 0 = no limit (the whole matching result set).

## `maxPagesPerQuery` (type: `integer`):

Pages of results to read per search before moving to the next one — 20 jobs a page. The Actor already stops at the true last page (ceil(total/20); the page after it returns 404, which is end-of-results, not an error), so this is only needed to sample the top N pages of a very large search. 0 = read every page.

## `deduplicateByJobId` (type: `boolean`):

Keep this on. Duunitori's index churns about 33% a week, so offset pagination re-shows some ads at depth — measured 0.8% duplicates over a contiguous 12-page head band and 9.5% over a contiguous 20-page band at depth 600. With this on, a job is delivered and billed exactly once per run no matter how many of your searches matched it.

## `onlyNewSince` (type: `boolean`):

Incremental mode for scheduled runs: remember every job id delivered, and on the next run return only ids that are new. State lives in a NAMED key-value store, so it survives between runs. With ~5,800 new ads a week, a daily or weekly schedule with this on returns just the delta.

## `stateKey` (type: `string`):

Which saved id list to compare against, inside the Actor's named key-value store. Use a different key per saved search so two schedules do not blank each other out.

## `descriptionFormat` (type: `string`):

How to return the ad body when 'Fetch job details' is on. HTML keeps the employer's formatting; text is what you feed an LLM; 'none' keeps the dataset small when you only want the structured fields.

## `descriptionMaxLength` (type: `integer`):

Cap the description length to keep the dataset small. Measured ad bodies run 2,500–8,400 characters. 0 = no truncation. Plain text is cut at the character limit; HTML is cut back to the last complete tag and any still-open tags are closed, so descriptionHtml stays valid markup rather than ending mid-element.

## `outputFormat` (type: `string`):

Flat gives one row per job with salaryMin/salaryMax/salaryCurrency/salaryUnit as top-level columns — the shape a spreadsheet or CSV export wants. Nested groups them under a 'salary' object for API consumers.

## `proxyConfiguration` (type: `object`):

duunitori.fi is behind Cloudflare and only serves Nordic residential exits. Measured 2026-09-04: no proxy → challenge; datacenter → challenge; residential pinned to FI → 200 with real data (98.7% over 158 calls). SE, NO, DK, DE and EE also clear; GB and US are challenged, so an UNPINNED residential pool fails intermittently. If you leave the country blank the Actor pins it to FI for you.

## `maxConcurrency` (type: `integer`):

Parallel detail/employer requests. The site answers in about 0.9–1.2 s and every request costs residential bandwidth, so there is nothing to gain from hammering it — 2 is a good default and keeps the block rate at the measured 0%.

## `requestRetries` (type: `integer`):

Attempts per URL, each on a FRESH proxy session. About one call in eighty comes back as a residential-pool hiccup ('590 UPSTREAM504, 0 bytes'); a new session clears it. End-of-results 404s are never retried.

## Actor input object example

```json
{
  "startUrls": [
    {
      "url": "https://duunitori.fi/tyopaikat/ala/ohjelmointi-ja-ohjelmistokehitys?filter_remote_work=1"
    }
  ],
  "searchQueries": [
    "ohjelmistokehittäjä",
    "developer"
  ],
  "searchDescriptions": false,
  "municipalities": [
    "helsinki",
    "tampere"
  ],
  "regions": [],
  "industries": [
    "ohjelmointi-ja-ohjelmistokehitys",
    "lakiala"
  ],
  "occupations": [],
  "employmentType": [],
  "contractType": [],
  "adLanguage": [],
  "remoteOnly": false,
  "withSalaryOnly": false,
  "greatPlaceToWorkOnly": false,
  "diversityPromiseOnly": false,
  "goodSummerJobOnly": false,
  "ageGroup": "any",
  "postedWithinDays": 0,
  "expiringWithinDays": 0,
  "includeExpiredOrClosed": true,
  "fetchJobDetails": true,
  "fetchEmployerProfile": false,
  "maxItems": 25,
  "maxPagesPerQuery": 0,
  "deduplicateByJobId": true,
  "onlyNewSince": false,
  "stateKey": "seen-job-ids",
  "descriptionFormat": "text",
  "descriptionMaxLength": 0,
  "outputFormat": "flat",
  "proxyConfiguration": {
    "useApifyProxy": true,
    "apifyProxyGroups": [
      "RESIDENTIAL"
    ],
    "apifyProxyCountry": "FI"
  },
  "maxConcurrency": 2,
  "requestRetries": 4
}
```

# Actor output Schema

## `items` (type: `string`):

The dataset of scraped duunitori.fi job ads (one job per row, deduplicated by job id).

# 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 = {
    "fetchJobDetails": true,
    "maxItems": 25
};

// Run the Actor and wait for it to finish
const run = await client.actor("scrapersdelight/duunitori-jobs-scraper").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 = {
    "fetchJobDetails": True,
    "maxItems": 25,
}

# Run the Actor and wait for it to finish
run = client.actor("scrapersdelight/duunitori-jobs-scraper").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 '{
  "fetchJobDetails": true,
  "maxItems": 25
}' |
apify call scrapersdelight/duunitori-jobs-scraper --silent --output-dataset

```

## MCP server setup

```json
{
    "mcpServers": {
        "apify": {
            "type": "http",
            "url": "https://mcp.apify.com/?tools=fetch-actor-details,scrapersdelight/duunitori-jobs-scraper"
        }
    }
}

```

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/Jws2zYFW3KLYNX5uT/builds/o4T4DMfx7T7zzPVhc/openapi.json
