# Polish Jobs Aggregator - every portal in one feed (`trev0n/pl-jobs-aggregator`) Actor

Searches Poland's biggest job boards at once - pracuj.pl, OLX, praca.gov.pl, RocketJobs and optionally justjoin.it — and returns one deduplicated feed with a single schema.

- **URL**: https://apify.com/trev0n/pl-jobs-aggregator.md
- **Developed by:** [Paweł](https://apify.com/trev0n) (community)
- **Categories:** Automation, Developer tools, Jobs
- **Stats:** 1 total users, 0 monthly users, 71.4% runs succeeded, 0 bookmarks
- **User rating**: No ratings yet

## Pricing

from $3.00 / 1,000 results

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

## 🇵🇱 Polish Jobs Aggregator — search or watch five job boards at once

One input, five Polish job boards, one clean feed. Search once, or leave it running as a **standing watch** that tells you only what is new since last time.

Everything runs inside this single Actor. You do not need to own, rent, or subscribe to any other Actor.

***

### 🚀 What it does

| | |
|---|---|
| **Searches five boards in parallel** | pracuj.pl, praca.gov.pl (the government database), OLX, RocketJobs.pl and justjoin.it |
| **Speaks one schema** | The five boards disagree on nearly every field name. You get 38 identical fields, whichever board an offer came from. |
| **Compares salaries honestly** | Monthly, hourly and daily rates are normalised to a monthly band, so `130 PLN/h` and `21 000 PLN/mth` sit in the same column. |
| **Merges duplicates** | The same job posted on three boards becomes one record that keeps all three links. |
| **Filters like a human would** | Keep only offers that actually mention your field; drop shift work; drop a city you did not ask for; drop anything older than N days. |
| **Watches, not just searches** | Turn on watch mode and every run returns only the offers that are NEW, that CHANGED, or that came back. |

***

### 👥 Who it is for

- **Job seekers with a narrow field.** "Chemical technology near Kraków, no shift work" is not a query any single board can answer. This is.
- **Recruiters and sourcers** who need one deduplicated market view instead of five browser tabs.
- **Analysts** tracking salaries, contract types, or how fast openings appear and disappear.
- **Anyone building a job alert** — watch mode is designed to run on a schedule and be read like an inbox.

***

### 🎛️ Two ways to run it

#### 1. Search — the whole matching feed, every run

```json
{
  "keyword": "magazynier",
  "location": "Warszawa",
  "maxItemsPerPortal": 25
}
```

Returns ~90 offers from four boards, deduplicated. Verified run: 92 records in 47 seconds.

#### 2. Watch — only what changed

```json
{
  "keywords": ["technolog", "laborant", "chemik", "kontrola jakości"],
  "location": "Kraków",
  "includeTerms": ["chemi", "laborator", "technolog", "kontrola jakości", "analiz"],
  "excludeTerms": ["praktyki", "staż bezpłatny"],
  "excludeShiftWork": true,
  "strictLocation": true,
  "incremental": true,
  "maxItemsPerPortal": 15
}
```

The **first** run records a baseline and returns everything that matched — 14 offers in the verified run, filtered down from 38 raw hits:

```
Unified 38 records from 4 portals
strictLocation: kept 28 of 38 in "Kraków"
Watch filters: 28 → 15 (12 off-topic, 1 shift work, 0 too old)
Dedupe: 15 → 14
First run — 14 offers recorded as the baseline
```

Every **later** run on the same input returns only what changed. Nothing new? One informational record saying so:

```json
{ "noResults": true, "reason": "Nothing new since the last run of this watch." }
```

Schedule it daily in Apify and it becomes a job alert that never repeats itself.

***

### 📋 More examples

<details>
<summary><b>IT jobs with salary bands</b></summary>

```json
{
  "keyword": "python",
  "location": "Kraków",
  "portals": ["justjoin.it"],
  "maxItemsPerPortal": 20
}
```

IT boards are excluded from a general search on purpose — for a warehouse query they are noise. Ask for them explicitly, or set `includeItBoards: true`.

Real output from this input: 9 of 10 salaried offers in PLN, including `130–170 PLN / hour` normalised to `21 840–28 560 PLN / month`.

</details>

<details>
<summary><b>Everything new in a city this week, no keyword</b></summary>

```json
{
  "location": "Gdańsk",
  "maxAgeDays": 7,
  "maxItemsPerPortal": 100,
  "incremental": true
}
```

</details>

<details>
<summary><b>Only the government database, with employer contact details</b></summary>

```json
{
  "keywords": ["opiekun", "opiekunka"],
  "location": "Lublin",
  "portals": ["praca.gov.pl"],
  "extractFullDetails": true
}
```

praca.gov.pl is the only source that publishes an employer phone number and e-mail. For sole traders that is personal data — treat it accordingly.

</details>

<details>
<summary><b>Watch a niche and report offers that disappeared too</b></summary>

```json
{
  "keywords": ["spawacz", "spawacz MIG"],
  "location": "Poznań",
  "incremental": true,
  "emitExpired": true,
  "skipReposts": true
}
```

`emitExpired` adds a record with `changeType: "EXPIRED"` for anything that vanished; `skipReposts` suppresses an employer re-listing the identical advert under a new ID.

</details>

***

### 🔍 The filters, and what they really do

| Option | What it does |
|---|---|
| `keywords[]` | Several phrases, each run as its own search, merged and deduplicated. A niche is rarely one word. |
| `includeTerms[]` | Keep an offer only if its **full text** mentions one of these. This is the relevance filter a job board cannot do — boards match titles, this reads the description. |
| `excludeTerms[]` | Kill a recurring false positive: `praktyki`, `staż`, an agency you never want to hear from. |
| `excludeShiftWork` | Drops shift and night work, including the spelled-out forms (`dwie zmiany`, `system trzyzmianowy`) while keeping `jedna zmiana`. Also switches on detail fetching, because the one authoritative schedule field only appears there. |
| `strictLocation` | Only the city you asked for. Off by default: what a board usually "leaks" is the commuter belt (a Warsaw search returns Pruszków, Piaseczno), and most people want that. |
| `maxAgeDays` | Ignore anything published longer ago than this. Offers with no date are kept — plenty of boards omit it. |
| `dedupeThreshold` | How similar two offers must be to count as one. `0.85` is the tested default. |

**On shift work — read this before relying on it.** There is exactly one authoritative schedule field across the five boards: praca.gov.pl's `zmianowość`. pracuj.pl looks like a second one but is not (its field says "Pełny etat" / "Część etatu" — an employment fraction, nothing about shifts). So the filter also reads the **full offer description**, where the signal genuinely is: on a warehouse search, 17 of 92 offers stated shift work in their text. Every returned offer is tagged with what was found and where it came from:

```json
{ "shiftSignal": "shift", "shiftSignalSource": "description" }
```

`shiftSignal: null` means **no board said anything** — it is not a claim of day work. If you would rather drop those too, set `unknownScheduleIsShift: true`, but expect a much shorter list.

***

### 📦 What you get

38 flat fields per offer — flat on purpose, so a CSV or Google Sheets export stays readable.

**Position** — `title`, `description`, `seniority[]`, `skills[]`, `contractType`, `workingTime`, `workMode` (remote / hybrid / office / mobile), `shiftSignal`, `shiftSignalSource`
**Employer** — `companyName`, `companyUrl`, `contactPhone`, `contactEmail` (praca.gov.pl only)
**Location** — `city`, `region`, `allCities[]` (a posting open in five cities lists five), `latitude`, `longitude`
**Money** — `salaryMin`, `salaryMax`, `salaryCurrency`, `salaryPeriod`, `salaryGross`, `salaryText`, plus `salaryMinMonthly` / `salaryMaxMonthly` for comparison, and `salaryPeriodInferred` when a period had to be deduced
**Provenance** — `url`, `applyUrl`, `sourcePortal`, `sourceId`, `duplicates[]`, `duplicateCount`, `isCrossPortal`, `sourcePortals[]`
**Dates** — `publishedAt`, `expiresAt`, `scrapedAt`
**Watch mode adds** — `changeType` (`NEW` / `UPDATED` / `REAPPEARED` / `EXPIRED` / `UNCHANGED`), `firstSeenAt`, `lastSeenAt`, `isRepost`, `repostOfId`

Sample record:

```json
{
  "sourcePortal": "justjoin.it",
  "title": "Senior Fullstack Developer (Python + Angular)",
  "companyName": "Ingenious.Build",
  "city": "Kraków",
  "allCities": ["Warszawa", "Kraków", "Wrocław", "Gdańsk", "Poznań"],
  "workMode": "remote",
  "salaryMin": 130,
  "salaryMax": 170,
  "salaryCurrency": "PLN",
  "salaryPeriod": "hour",
  "salaryGross": false,
  "salaryMinMonthly": 21840,
  "salaryMaxMonthly": 28560,
  "contractType": "b2b",
  "seniority": ["senior"],
  "skills": ["PHP", "PHP 8", "DDD", "REST API", "Laravel"],
  "shiftSignal": null,
  "shiftSignalSource": null,
  "publishedAt": "2026-08-18T17:00:20Z",
  "url": "https://justjoin.it/job-offer/...",
  "applyUrl": "https://grnh.se/8j8j36m05us",
  "duplicateCount": 0,
  "isCrossPortal": false
}
```

Every run also writes a **`RUN_SUMMARY`** to the key-value store: what each portal returned, what each filter removed, and what the deduplicator merged. If a run comes back short, that record says which stage is responsible.

***

### 🗺️ The boards, and how each one is read

| Board | What it is | How it is read |
|---|---|---|
| **pracuj.pl** | The largest general board | Search pages, Polish residential IP (Cloudflare) |
| **praca.gov.pl** | Government database, ~46 000 vacancies | Its public API |
| **OLX** | Local and blue-collar work | Search pages, then the site's own offer API |
| **RocketJobs.pl** | Non-IT, ~15 500 active offers | Sitemap of active offers, then each offer page |
| **justjoin.it** | IT only, ~9 900 active offers | Sitemap of active offers, then each offer page |

RocketJobs and justjoin are read from the public site because their JSON API was withdrawn — it answers `503` on every path, `/health` included. The sitemap route is cheaper anyway: two requests enumerate the entire board, offers are filtered on the URL before anything is downloaded, and each offer page carries the complete record — salary band, required skills, seniority and the employer's own application link.

**A failing board never fails the run.** It is reported instead, with the distinction that matters: `SUCCEEDED`, `EMPTY` (it ran and had nothing for you) or `FAILED` (it broke). A board that is down will never be presented to you as an empty job market.

***

### 🔁 How duplicates are merged

Tuned on a 3 247-offer experiment, not on guesswork:

- **Parenthetical content is compared, not deleted.** Deleting it merged `(Angular, Java)` with `(Java+React)` — two different openings at one software house.
- **Seniority is a veto** when both sides state it, and a penalty when only one does.
- **Salary only vetoes when both sides quote money**, because outside IT only about a third of offers do.
- **Complete-linkage clustering** — single linkage chained unrelated roles together through one shared middle record.
- **A shared placeholder is not a shared identity.** praca.gov.pl anonymises employers as literally `"kontakt przez PUP"`, and six unrelated warehouses sharing that string were being fused into one offer.

Merged records keep every source link in `duplicates[]`. Prefer them separate? Set `keepDuplicatesSeparate: true`.

***

### ❓ FAQ

**Do I need any other Actor, subscription or API key?**
No. Everything runs here.

**Do I need a proxy?**
pracuj.pl and OLX need a Polish residential IP; the default input already asks for one. The other three boards work without any proxy, and are not sent through one by default so you do not burn residential traffic for nothing.

**How does watch mode remember?**
In a named key-value store, keyed by your query *and* your filters. Change the search or a filter and you get a fresh baseline rather than silently inheriting the old one. `resetState: true` forgets a baseline on purpose.

**Why did my watch return nothing?**
Because nothing changed — that is the normal outcome. `RUN_SUMMARY` shows the whole funnel if you want to confirm.

**Can I get the full description?**
`extractFullDetails: true`. It is slower and costs more, but it also gives you `applyUrl`, which is the strongest signal for spotting the same job on two boards.

**Are salaries comparable across offers?**
Within one currency, yes — that is what `salaryMinMonthly` is for. Across currencies, no: the boards quote some IT offers in EUR, USD or CHF and no exchange rate is applied. The currency is always on the record.

***

### ⚖️ Legal and ethical use

Public listing data only. No logins, no paywalls, no private profiles. Offer enumeration uses each site's own published sitemaps. praca.gov.pl publishes employer contact details, which for sole traders is personal data — if you use those fields, that is your GDPR responsibility, so keep them out of anything you redistribute.

***

### 🛠️ Something missing?

Another board, a different filter, a webhook into your ATS or a Slack alert on every new match — open an issue on the Actor page.

# Actor input Schema

## `keyword` (type: `string`):

What to search for, e.g. "laborant", "kierowca", "kontrola jakości". Searched across every selected portal.

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

Several phrases, each run as its own search and merged into one deduplicated feed. Use this for a niche watch, where no single phrase covers the field: \["technolog", "laborant", "chemik", "kontrola jakości"]. Overrides "Keyword" when set.

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

City name or slug, e.g. "krakow" or "Gdańsk". Leave empty to search the whole country.

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

How far around the city to look. Honoured by the portals that support a radius; the others match the city itself.

## `category` (type: `string`):

Optional category for the portals that have one, e.g. "produkcja", "logistyka", "gastronomia".

## `portals` (type: `array`):

Which portals to search. Leave empty for all the general boards.

## `includeItBoards` (type: `boolean`):

Off by default: for a general search the IT boards are mostly noise.

## `maxItemsPerPortal` (type: `integer`):

Each portal is capped separately, so one huge board cannot crowd out the rest.

## `extractFullDetails` (type: `boolean`):

Fetch each offer's page for the full description and the employer's application link. Slower and pricier, but the application link is also the strongest signal for spotting the same offer on two portals.

## `strictLocation` (type: `boolean`):

Drop offers outside the city you asked for. Off by default because most of what a portal adds is the commuter belt (a Warsaw search returns Pruszków, Piaseczno, Grodzisk) and most people want those. Turn it on when you mean the city itself and nothing else.

## `includeTerms` (type: `array`):

Keep an offer only if its title, company or description mentions one of these. This is the relevance filter a portal's own search cannot do: it reads the whole text, not just the job title. Diacritics and letter case are ignored.

## `includeTermsMode` (type: `string`):

"any" keeps an offer that mentions at least one term (right for a list of synonyms). "all" demands every term.

## `excludeTerms` (type: `array`):

Drop an offer if its text mentions any of these — the fastest way to kill a recurring false positive ("praktyki", "staż", "agencja pracy", a company you do not want to hear from again).

## `excludeShiftWork` (type: `boolean`):

Drop offers whose schedule the portal reports as shift or night work — including the spelled-out forms ("dwie zmiany"), while keeping "jedna zmiana". Reads the portals' own schedule fields, never the description, because the description never says. Turning this on also fetches offer details, since praca.gov.pl only ships its schedule field there.

## `unknownScheduleIsShift` (type: `boolean`):

Strict companion to "No shift work": most listings say nothing about the schedule, so this empties the feed considerably. Off by default; survivors are tagged shiftSignal: null so you can see which were never confirmed.

## `maxAgeDays` (type: `integer`):

0 = no age limit. Offers with no publication date are kept, because plenty of portals omit it.

## `incremental` (type: `boolean`):

Turns the run into a standing watch. The first run records a baseline; every later run on the same input returns only NEW, UPDATED and REAPPEARED offers. Put it on a schedule and read it like an inbox.

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

Optional label for the watch's memory. Leave empty and it is derived from the query and filters, so changing the search starts a fresh baseline instead of silently inheriting the old one.

## `emitUnchanged` (type: `boolean`):

Watch mode only. Off by default — the point of a watch is what changed.

## `emitExpired` (type: `boolean`):

Watch mode only. Emits a record with changeType EXPIRED for every offer that was there last run and is gone now.

## `skipReposts` (type: `boolean`):

Watch mode only. An employer re-listing word-for-word the same expired offer is matched by content and suppressed instead of reported as new.

## `resetState` (type: `boolean`):

Watch mode only. Wipes this watch's memory for one run, so everything counts as new again.

## `dedupeThreshold` (type: `number`):

How similar two offers must be to count as one (0.7 merges aggressively, 0.95 barely merges). 0.85 is the tested default.

## `keepDuplicatesSeparate` (type: `boolean`):

Return every portal's copy as its own record. Useful when you want to compare how the same offer is worded across boards.

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

Used only by the portals that need a proxy — pracuj.pl requires Polish residential IPs (it returns nothing without them) and OLX gets a cheap datacenter IP. The other three boards answer a bare request, so they are never sent through a proxy and never spend your residential traffic. Per-portal control lives in "portalProxies".

## `portalProxies` (type: `object`):

Override the proxy for one portal, e.g. {"pracuj.pl": {"useApifyProxy": true, "apifyProxyGroups": \["RESIDENTIAL"], "apifyProxyCountry": "PL"}, "olx.pl": null}. `null` disables the proxy for that portal. Residential traffic is the single biggest cost in a run, so this is where to tune it.

## Actor input object example

```json
{
  "keyword": "magazynier",
  "keywords": [],
  "location": "Warszawa",
  "radiusKm": 30,
  "portals": [],
  "includeItBoards": false,
  "maxItemsPerPortal": 10,
  "extractFullDetails": false,
  "strictLocation": false,
  "includeTerms": [],
  "includeTermsMode": "any",
  "excludeTerms": [],
  "excludeShiftWork": false,
  "unknownScheduleIsShift": false,
  "maxAgeDays": 0,
  "incremental": false,
  "emitUnchanged": false,
  "emitExpired": false,
  "skipReposts": false,
  "resetState": false,
  "dedupeThreshold": 0.85,
  "keepDuplicatesSeparate": false
}
```

# Actor output Schema

## `overview` (type: `string`):

No description

## `duplicates` (type: `string`):

No description

# 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 = {
    "keyword": "magazynier",
    "location": "Warszawa",
    "maxItemsPerPortal": 10
};

// Run the Actor and wait for it to finish
const run = await client.actor("trev0n/pl-jobs-aggregator").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 = {
    "keyword": "magazynier",
    "location": "Warszawa",
    "maxItemsPerPortal": 10,
}

# Run the Actor and wait for it to finish
run = client.actor("trev0n/pl-jobs-aggregator").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 '{
  "keyword": "magazynier",
  "location": "Warszawa",
  "maxItemsPerPortal": 10
}' |
apify call trev0n/pl-jobs-aggregator --silent --output-dataset

```

## MCP server setup

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

```

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/OtW6XgIozz8P84DbT/builds/ebExqHhsoZlLZ8fZS/openapi.json
