# Multi Job Board Scraper: LinkedIn, Indeed, Glassdoor + Dedup (`flash_scraper/multi-jobboard-scraper`) Actor

Multi job board scraper, job board aggregator and job aggregator: LinkedIn, Indeed, Glassdoor, The Muse + 8 keyless boards in one run, deduplicated. New jobs alert and job monitoring on a schedule; ATS job scraper for Greenhouse, Lever, Ashby. Multi source job scraper with dedup, no API key.

- **URL**: https://apify.com/flash\_scraper/multi-jobboard-scraper.md
- **Developed by:** [Flash Scrape](https://apify.com/flash_scraper) (community)
- **Categories:** Jobs, Lead generation, Automation
- **Stats:** 48 total users, 28 monthly users, 99.6% runs succeeded, 6 bookmarks
- **User rating**: 5.00 out of 5 stars

## Pricing

from $4.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.
Since this Actor supports Apify Store discounts, the price gets lower the higher subscription plan you have.

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

## Multi Job Board Scraper: LinkedIn, Indeed, Glassdoor + Dedup

**Multi Job Board Scraper** is a job board aggregator on Apify that runs one search across LinkedIn, Indeed, Glassdoor, The Muse and eight keyless job boards and returns one deduplicated row per role — a job posted on three boards bills once, not three times.

**Try it:** [New Job Alerts: Daily Job Monitor, Only New Postings](https://apify.com/flash_scraper/multi-jobboard-scraper/examples/new-jobs-daily-monitor)

### At a glance

| | |
|---|---|
| **Boards** | LinkedIn, Indeed, Glassdoor and The Muse by default, plus 8 keyless job boards (9 keyless sources counting The Muse) — 12 working boards in one run. Five more (Google Jobs, ZipRecruiter, Bayt, BDJobs, Naukri) are blocked at the source and the Board status table below says so |
| **Dedup** | One deduplicated row per role across boards — a job posted on three boards bills once; `found_on_sites` and `duplicate_count` show the merge on the row |
| **Price** | $0.005 per deduplicated job — $5 per 1,000 on the free plan; paid plans pay less (Pricing below; the Pricing tab is authoritative). Rows removed by a filter are never billed |
| **New jobs alert** | Tick *Only new jobs*, schedule it, and each run delivers and bills only postings it has not seen before — with Slack / Discord / webhook digests via `webhookUrl` |
| **Keys / proxy** | No login, no API key, nothing to bring — every row is public data; Apify's datacenter proxy pool is the measured default |
| **Export** | CSV, JSON or Excel — the same 54 columns on every row, seven saved Output views |

![Run report: jobs per board, top companies and the first rows of a real run](https://api.apify.com/v2/key-value-stores/XtUUgXag7DIismvPm/records/multi-jobboard-scraper-report.png)

### Pricing

| Event | Free plan | Paid plans |
|---|---|---|
| Deduplicated job delivered | **$0.005** ($5 per 1,000) | less — $0.0045 on Bronze down to $0.0035 on Diamond (live pricing record read 2026-08-29; the **Pricing** tab on this page is always authoritative) |
| Run start | $0.00005 | $0.00005 |

Two worked costs at the free-plan rate: an untouched form (the four default boards, up to 20 rows each — the input schema's defaults) delivers at most 80 deduplicated jobs for at most $0.40; a 500-job sweep costs $2.50. A job found on three boards bills once, rows removed by a filter bill nothing, and a failed or empty run bills only the $0.00005 start.

### Use from an AI agent

- **MCP:** point Claude, ChatGPT, Cursor or any MCP client at `https://mcp.apify.com?tools=flash_scraper/multi-jobboard-scraper`; the tool is named after the Store slug and takes this actor's input unchanged. Keywords for the server's `search-actors` tool: *LinkedIn jobs*, *Indeed jobs*, *job postings*, *job board scraper*, *job listings*. Tool-name spellings, payment without an Apify token and measured timings: [Use it from an AI agent (MCP)](#use-it-from-an-ai-agent-mcp).
- **Smallest useful call** (Python `apify-client`; the same JSON works in the Console, the REST API and n8n/Make/Zapier):

```python
from apify_client import ApifyClient
client = ApifyClient("<APIFY_TOKEN>")
run = client.actor("flash_scraper/multi-jobboard-scraper").call(run_input={"searchTerm": "data analyst", "location": "New York, NY"})
rows = client.dataset(run["defaultDatasetId"]).list_items().items
```

- **Output contract:** the same 54 columns on every row of every run — a field a board did not publish is `null`, never a missing column. The full field list with measured fill rates is under [Output fields and how often they are filled](#output-fields-and-how-often-they-are-filled); every run also writes a machine-readable `RUN_SUMMARY` record to its key-value store.

### Board status — read this first

We would rather tell you than let you find out on a paid run. Checked **2026-08-07**, re-measured **2026-08-11**: all boards work from Apify's default datacenter pool (52 of 54 rows vs residential on the same search, faster and far cheaper). Residential remains available in Proxy configuration if a board starts coming back short:

| Board | Status | Notes |
|---|---|---|
| **LinkedIn** | Working (default) | Full job details fetched by default — descriptions on every LinkedIn row |
| **Indeed** | Working (default) | Richest company data (size, revenue, addresses) |
| **Glassdoor** | Working (default), intermittently blocked | Salary on ~100% of rows it returns, plus employer rating. Glassdoor 403s some runs entirely (upstream, comes back on its own within hours) — when that happens the run status names it and the missing board bills nothing. Tops out around ~28-30 rows per query — a board-side cap, not a bug |
| **The Muse** | Working (default since 2026-08-14) | The only extra board with real city coverage (server-side location filtering — measured 12/12 success from the standard proxy pool, 20 rows/page). Spell places its way — `New York, NY`, `Toronto, Canada`: a spelling it does not know is reported as a Muse failure naming the fix instead of billing its remote pool (measured 2026-08-29), and `Remote` as the location maps to that pool. Carries **no salary data** — 0 salary fields in 220 measured rows |
| **Remotive** | Working (remote-only) | Auto-joins remote searches; rows may **not** be re-posted to other job boards (see Licensing below) |
| **Jobicy** | Working (remote-only) | Auto-joins remote searches; credit Jobicy + keep the original apply links |
| **Himalayas** | Working (remote-only) | Auto-joins remote searches; link back to himalayas.app |
| **HN "Who is hiring?"** | Working (remote-only) | Startup jobs from the monthly Hacker News thread |
| **Remote OK** | Working (remote-only) | 100 newest remote listings, server-side tag filtering. Rows keep their remoteok.com link — **required attribution, see Licensing** |
| **We Work Remotely** | Working (remote-only) | Category RSS feeds (25-100 rows); salary parsed from listing text where published (~56% of programming posts) |
| **Working Nomads** | Working (remote-only) | Curated remote list (~50 rows); no salary data |
| **DevITjobs US** | Working (opt-in) | US/Canada tech board, **salary on ~100% of rows** (annual, currency as published), experience level + tech stack on nearly every row |
| **DevITjobs UK** | **Discontinued upstream (2026-08-29)** | The board's public endpoint now redirects to a signup page (re-probed 2026-09-05). It stays selectable in case it returns, is named in the run status and bills nothing. Eleven of the twelve working boards deliver rows today |
| Google Jobs | Blocked at source | Google serves a bot-check page instead of results |
| ZipRecruiter | Blocked at source | API returns HTTP 403 |
| Bayt (MENA) | Blocked at source | HTTP 403 |
| BDJobs (Bangladesh) | Blocked at source | Redirects away from results |
| Naukri (India) | Blocked at source | Demands a CAPTCHA |
| **Company boards** (`atsCompanies`) | Working (opt-in) | Greenhouse, Lever, Ashby, Recruitee, BambooHR, Workable — each company's own careers API, no search needed; see *Can I scrape a specific company's careers page?* below |

In a verified run (2026-08-08, `python developer`, remote only, 10 requested per board) the five API boards delivered: The Muse 10, Jobicy 10, HN "Who is hiring?" 5, Himalayas 6, Remotive 2 rows. Per-board counts track each board's live inventory for the term and move day to day (an earlier run saw Remotive 10 and Himalayas 9).

LinkedIn, Indeed, Glassdoor and The Muse are the **default selection**. The other boards are opt-in, with one convenience: when **Remote jobs only** is on and the board list is left at its default, the seven remote-only boards (Remotive, Jobicy, Himalayas, HN, Remote OK, We Work Remotely, Working Nomads) join the run automatically — `RUN_SUMMARY.remote_boards_auto_added` records exactly which ones were added. They are deliberately **not** added to location searches: for a query like *nurse in Dallas* they contribute nothing, so we don't run them. The Muse is the exception with real US city coverage, but its category-based search means **every word of your search term must appear in the job's title or description** — keep Muse search terms short.

The blocked five stay selectable in case they recover, and they cost you nothing when they return no rows — but do not plan a project around them today.

Every run writes a **`RUN_SUMMARY`** record to its key-value store with the exact per-board outcome, so you always know which board gave you what and why one was quiet. It also carries `columns_per_row` and the full `columns` list, so you can diff a run's schema without reading a single row. If *every* board you selected fails to fetch, the run is marked **FAILED** rather than quietly succeeding with an empty dataset.

Every successful run also sets a **status message** in the Console run header — row count, column count, and which preset fired and what it changed. Previously that line only appeared when search terms had been dropped, so a run driven by a preset finished without ever saying which preset had run.

***

### What it does

**One search. Up to twelve job boards. One deduplicated row per role — so a job posted to LinkedIn, Indeed *and* Glassdoor bills you once, not three times.**

LinkedIn, Indeed and Glassdoor plus The Muse and 8 keyless boards (9 keyless sources: The Muse, Remotive, Jobicy, Himalayas, Hacker News "Who is hiring?", Remote OK, We Work Remotely, Working Nomads, DevITjobs US/UK) merged into a single clean table: title, company, location, salary (min/max/interval/currency plus annualized columns), remote flag, job type, posting date, full description, employer rating and company details. Search by keyword + location, filter by remote / job type / recency / salary present / title keywords / company, and export to **CSV, JSON, or Excel**.

**Key facts:**

- **12 working job boards in one run** — LinkedIn, Indeed, Glassdoor and The Muse by default, plus 8 keyless boards you can add.
- **$0.005 per deduplicated job ($5 per 1,000)** — a job found on several boards bills once, and filtered rows are never billed.
- **No login, no API key** — the actor never logs in anywhere; every row is public data.
- **Same 54 columns on every row**, exported as CSV, JSON or Excel, with salaries normalized to annual for sorting.
- **Monitoring mode + webhook alerts** — schedule it with *Only new jobs* and it delivers and bills only unseen postings, with Slack / Discord digests via `webhookUrl`.

**Pricing:** pay per row — **$0.005 per deduplicated job ($5 per 1,000)** from 2026-08-28, plus $0.00005 per run start; the live rate is always on this page's **Pricing** tab. A job found on three boards bills once, filtered rows are never billed, and a failed or empty run bills no per-row events (only the $0.00005 start).

**What it does not do:** it never logs in anywhere and never auto-applies — every row is public data. It cannot reach behind login walls (for LinkedIn it reads the pay span on the public search card when a posting carries one, plus posting text — the fuller base-pay block that LinkedIn's public job page shows on some postings, measured 2 of 6 on 2026-08-29, is not parsed by this actor yet), it is not a historical archive (each run reads the boards' current listings), and five selectable boards (Google Jobs, ZipRecruiter, Bayt, BDJobs, Naukri) are blocked at the source today — see the Board status table below.

**Watch specific companies' careers pages** — list company names in **Watch companies** and the actor reads each one's own ATS board directly (Greenhouse, Lever, Ashby, Recruitee, BambooHR or Workable — all keyless, tried in that order) and merges the live openings into the same 54-column table. Fresher than any job board, straight from the source. Leave the search term untouched and you get each company's whole board; type a term and only matching roles are delivered and billed.

**Only pay for NEW jobs on a schedule** — turn on **Only new jobs (monitoring mode)** and the actor remembers every job this search has already delivered to you (in a private store in your own account) and bills only postings it has not seen before. Measured before this existed: two identical runs a day apart shared 47% of their rows — a schedule was re-buying half its data every run. Memory is kept per search for 90 days.

**Set up a daily new-jobs feed in 60 seconds** — the recurring setup most buyers actually want:

1. Fill in your search term and location, tick **Only new jobs (monitoring mode)**, hit **Save & start** once.
2. On the run page choose **Actions → Schedule**, pick daily (or hourly for hot markets).
3. Done. Every run now delivers — and bills — only postings the previous runs have not seen. The first run seeds the memory; identical reruns bill zero (measured: 15 rows, then 0). Point the schedule's webhook at Slack, a Google Sheet via Zapier/Make, or your ATS, and it becomes a job alert service you own.

**Remote jobs across eleven boards in one click** — turn on **Remote jobs only** and hit Save & start: Indeed, Glassdoor and The Muse are asked for their own remote filters, LinkedIn rows are gated by their own `is_remote` column after scraping — LinkedIn's guest search accepts `f_WT` and ignores it, so the Actor forwards the flag and enforces it itself, before billing (measured) — while the seven remote-only boards (Remotive, Jobicy, Himalayas, HN Who-is-Hiring, Remote OK, We Work Remotely, Working Nomads) join automatically. No other job-board actor on the Store combines the big boards' remote filters with the remote-only APIs in one run. (Only want the remote-only boards, without LinkedIn / Indeed / Glassdoor? The leaner [Remote Job Aggregator](https://apify.com/flash_scraper/remote-job-aggregator) sweeps the ten keyless remote boards at $2 per 1,000.)

Built for **recruiters & staffing agencies, job boards & aggregators, market researchers, and sales teams tracking hiring signals** who want one consolidated jobs feed instead of running eight scrapers and reconciling them by hand.

#### What you get

- **Twelve working boards in one run** — LinkedIn, Indeed, Glassdoor and The Muse selected by default, plus eight keyless boards: seven remote-only ones (Remotive, Jobicy, Himalayas, HN "Who is hiring?", Remote OK, We Work Remotely, Working Nomads) that join remote searches automatically, and DevITjobs US/UK you switch on. One schema, one dataset.
- **Cross-board dedup that shows its work** — the same role on several boards becomes one row carrying `found_on_sites` and `duplicate_count`. Company names are normalized first, so *Wipro* and *Wipro Limited* collapse together. It merges *across* boards only: five genuinely different openings one employer posted to one board stay five rows, because you paid to scrape all five. **You are billed for the surviving row only.**
- **Salaries you can actually sort** — hourly, weekly and monthly pay is normalized into `salary_min_annual` / `salary_max_annual` on every salaried row, no flag to find.
- **Filters that never bill you** — salary-required, title / company / city excludes, competitor-only, experience level, agency excludes and max posting age all run *before* the charge, and `RUN_SUMMARY.filter_removed` counts each one.
- **Seven ready-made Output views** — Overview, Salary, Cross-board, Remote & location, Company info, Standard table, All columns. No column wrangling on your first run (exports still carry every column).
- **The same 54 columns on every row, every run** — a field a board did not publish is `null`, never a missing column. Spreadsheet imports line up, `pd.read_csv` behaves, positional parsers keep working.
- **Presets and quick-pickers** — pick a goal and the matching options switch themselves on, with every change (and every refusal) written to the log.
- **Paste a search URL** — already built the search on LinkedIn, Indeed or Glassdoor? Drop the URL in and skip the form.
- **Honest per-board reporting** — every run writes a `RUN_SUMMARY` naming each board's real outcome. Boards that are blocked at the source are labelled in the table below, not sold to you.

#### Measured, not promised

Every number below comes from a real run of this Actor. Inputs and dates are given so you can reproduce them.

| Run | Result |
|---|---|
| Untouched form, pressed Start (the then-default 3 boards, 20/board) — 2026-08-08 | **60 rows in 27 s** — LinkedIn 20, Indeed 20, Glassdoor 20. When Glassdoor is having one of its intermittent blocked spells you get ~40 rows from the other two, the status message says so, and the missing rows are never billed |
| `data analyst` · New York, NY · 30/board, 3 boards — 2026-08-08 | 90 raw listings → **89 billed rows in 39 s**; 1 duplicate merged across boards (Indeed 30, Glassdoor 30, LinkedIn 29) |
| Salary coverage on that same run | `salary_min_annual` filled on **74 of 89 rows (83%)** |
| Stress run, 120/board, 3 boards — 2026-08-07 | **258 rows in 226 s** — LinkedIn 118, Indeed 112, Glassdoor 28. Measured before the 2026-08-29 LinkedIn paging fix (the scraping library skipped result offsets 20–29, 40–59, … on runs above 20 LinkedIn rows, so those 118 were not the newest 118); not yet re-measured on the fixed build |
| Remote-only run, the five API boards, 10 requested each — 2026-08-08 | The Muse 10, Jobicy 10, HN 5, Himalayas 6, Remotive 2 — per-board totals track each board's live inventory for your term and vary day to day |
| LinkedIn descriptions on a default run — 2026-08-07 | **20 of 20 LinkedIn rows** (detail fetching is on by default) |

Glassdoor tops out around ~28–30 rows per query no matter the cap — a board-side limit we report rather than hide. The Muse joined the default board set on 2026-08-14, so an untouched run now sweeps four boards. Every row of every run carries the same 54 columns; per-field fill rates are further down, under *Output fields and how often they are filled*.

#### First run

```json
{
  "searchTerm": "data analyst",
  "location": "New York, NY"
}
```

That is a complete run. Everything else already has a working default: LinkedIn + Indeed + Glassdoor + The Muse, 20 job postings per board, full LinkedIn job details on, Apify datacenter proxy (measured: same rows as residential at a fraction of the cost). Open the Actor, hit **Save & start** without touching anything, and you get rows.

#### Presets

Pick a goal in **Preset** and the matching options are switched on for you. A preset **only fills options you left at their default** — anything you set yourself always wins — and both the run log and `RUN_SUMMARY.preset_applied` list exactly what it changed *and* what it refused to change, with your conflicting value.

| Preset | What it switches on | Effect on your bill |
|---|---|---|
| **Salary research** | `requireSalary`, `enforceAnnualSalary`, `sortBy: salary_desc` | **Lower** — rows carrying no pay are dropped before billing |
| **Competitor hiring intel** | `includeCompanyDetails` (a no-op since v0.1.48 — the company block ships on every run) | **Unchanged** — it does *not* narrow the run by itself: add the employers you are watching under **Only these companies**, or the run stays unfiltered and the log says so |
| **Remote-only sweep** | `isRemote` | **Higher** — with the board list left untouched this also brings in the seven remote-only boards (Remotive, Jobicy, Himalayas, HN, Remote OK, We Work Remotely, Working Nomads), and extra boards add rows |
| **Fresh postings only** | `maxAgeDays: 7`, plus a 168-hour board-side window when that cannot clash with your job type / easy-apply / remote choices | **Lower** — anything older is dropped before billing |
| **Recruiter lead-gen** | `excludeAgencies` (plus `includeCompanyDetails`, a no-op since v0.1.48) | **Lower** — staffing-agency reposts are dropped before billing |

Two quick-pickers sit beside it: **Role (quick pick)** and **City (quick pick)**. Precedence is `searchTerms` > `roleSelect` > `searchTerm` and `locations` > `citySelect` > `location` — a picker overrides the matching free-text box (and warns you in the log when it does), while the multi-value arrays override the picker. Picking a non-US metro also switches `countryIndeed` to that country unless you set it yourself. Leave all three shortcuts alone and the Actor behaves exactly as it always has.

#### What the Output tab looks like

Seven views ship with the Actor; pick one above the results table instead of scrolling a wall of 54 raw columns. **Overview** is what opens by default.

| View | Columns | Use it for |
|---|---|---|
| **Overview** | logo, title, company, location, salary, remote, posted, source, match %, link | the first read of any run |
| **Salary** | readable salary, raw min / max / currency / period, yearly-normalized min / max, salary source | comp benchmarking on one axis |
| **Cross-board** | found on boards, listings for this role, applicants (LinkedIn), posted and updated dates | proof of the dedup, hiring-urgency signals |
| **Remote & location** | remote flag, work arrangement (Indeed), location, matched location, source | telling true remote from remote-ish |
| **Company info** | industry, size, revenue, rating, reviews, company page, website, address, logo | employer research (Indeed fills most of it) |
| **Standard table** | the original 16 columns | the pre-2026-08-29 default view, field for field |
| **All columns** | every one of the 54 columns, labeled | the whole row on screen |

Views are **column presets, not filters**: *Salary* still lists rows whose board published no pay (those cells are simply blank) and *Cross-board* still lists roles seen on a single board (`listings for this role = 1`). To actually remove rows, use `requireSalary` or `isRemote` — rows removed by a filter are never billed. Every CSV / JSON / Excel export from the Dataset API hands you all 54 columns unless you ask for a view: the API accepts `?view=<key>`, and in the Console you select the view above the table, then **Export** — see *How to read the output* below. Until 2026-08-29 `?view=overview` returned the 16-column *Standard table*; it now returns the 10-column *Overview*, and the old set is addressable as `?view=standard`.

***

### Why use this

- **Twelve boards, one run** — LinkedIn + Indeed + Glassdoor + The Muse by default, plus eight keyless job boards (Remotive, Jobicy, Himalayas, HN "Who is hiring?", Remote OK, We Work Remotely, Working Nomads, DevITjobs US/UK) merged into a single dataset.
- **Paste a search URL** — already built the search on LinkedIn, Indeed or Glassdoor? Copy the URL into `searchUrls` and skip the form. Keywords, location, radius (LinkedIn and Indeed only), remote flag, recency, job type, experience level and sort are read straight out of the URL — every honored parameter is listed below (with one LinkedIn caveat: `f_WT` / `f_JT` are forwarded but ignored at the source, see the table), and an unparseable URL is reported instead of quietly scraping something else. Note this replaces the search term, locations and board list only; a **preset** and your filters still apply on top.
- **Keyword × location matrix** — `locations` runs every search term against every location and merges the results, which is how you get past a board's per-query result cap (one query for "United States" returns one capped page; five city queries return five).
- **Deduplicated, and it tells you** — the same job on multiple boards becomes one row carrying `found_on_sites` (every board it appeared on) and `duplicate_count`. Company names are normalized before matching (legal suffixes stripped), so *Wipro* on one board and *Wipro Limited* on another collapse into one billed row. Being live on three boards is a real hiring-urgency signal. Within a single board two rows are merged only when they are the same listing (same `job_url`, else same `id`), so a company advertising six distinct *Software Engineer II* roles on Indeed gives you six rows, not one. Read `duplicate_count` as **how many listings in this run shared this title and company**, not as a count of rows we deleted: across boards those extras really were merged into the one billed row, while on a single board they each kept their own row, so the same number can legitimately appear on several rows.
- **Filters that never bill** — salary-required, job type, remote-only, title-keyword excludes, company excludes, company *includes* (competitor hiring watch), experience level, city excludes, staffing-agency excludes, strict keyword match and max posting age all run after dedup and **before billing**; a filtered row is never charged, and `RUN_SUMMARY.filter_removed` says exactly how many each filter took.
- **Every job scored for keyword fit** — each row carries `matched_keywords` and `keyword_match_percent`, scored against your `resumeKeywords` when you give them and against the run's own search terms otherwise. Pure annotation: it never removes a row, never changes the bill, and `sortBy: relevance` puts the best matches on top.
- **Comparable salaries** — hourly, weekly and monthly pay is normalized into `salary_min_annual` / `salary_max_annual` (hourly ×2080, weekly ×52, monthly ×12), so one column sorts every salaried row.
- **Resilient** — a slow or blocked board is skipped on a timeout so the run still finishes with everything the others returned. No all-or-nothing failures. And a run that runs out of time is stopped *by the actor*, a safe margin before the platform's own limit: if the run had already assembled its rows for delivery they are pushed to your dataset (once — never twice, so nothing is billed twice), and the status message says what the run was doing when it stopped, instead of the run being killed at the timeout with everything thrown away (`RUN_SUMMARY.status: "stopped-at-time-limit"`). A run stopped **before** it got that far delivers nothing and bills nothing, and says so.
- **Honest per-board reporting** — `RUN_SUMMARY` names the board and the actual HTTP error, instead of a silent empty result.
- **Multi-search** — pass several search terms in one run; results are merged and deduped, and each row records which term matched it.
- **Clean text** — descriptions are converted to real Markdown without the stray backslash escapes (`full\-time`, `Web3 \| NYC`) that the underlying library emits by default.
- **Export anywhere** — CSV, JSON, Excel, or pipe to Google Sheets / your CRM.

***

### How to use it

The shortest complete run is a search term and a location: LinkedIn, Indeed, Glassdoor and The Muse are searched at 20 job postings per board with full LinkedIn job details on and Apify's datacenter proxy, and an untouched form pressed on 2026-08-08 delivered 60 rows in 27 seconds (LinkedIn 20, Indeed 20, Glassdoor 20). Everything below is optional.

0. *(Shortcut)* Already have the search open on LinkedIn, Indeed or Glassdoor? Copy the URL into **Search URLs** and hit Save & start — everything below is filled in from the URL.
1. Enter a **search term** (job title/keywords) and a **location** (or several under **Locations**, which runs every term against every location).
2. Keep the default boards (LinkedIn, Indeed, Glassdoor, The Muse), and set **Max job postings per board**. For remote searches, turning on **Remote jobs only** auto-adds the seven remote-only boards; DevITjobs US/UK are the only working boards you must add yourself.
3. **Full LinkedIn job details** is on by default — that's where LinkedIn descriptions, `job_type` and `company_industry` come from. Turn it off only for faster runs.
4. Optionally filter: remote-only, job type, posted-within-N-hours, distance, country — plus the post-scrape filters below (salary required, title/company excludes, max posting age), which are never billed.
5. Run → get a clean, deduplicated jobs table.

#### Input

The Console form is grouped into sections that go from loudest to quietest — **Quick start**, **What to search**, **Paste a job-search URL**, **Filters & limits**, **Board-specific options**, **Output**, and a deliberately plain **Advanced (networking)**. The optional plumbing lower down is grouped at the bottom on purpose, because a default run needs none of it.

| Field | Type | Description |
|---|---|---|
| `preset` | string | Optional goal shortcut: `salary_research`, `competitor_intel`, `remote_only`, `fresh_postings`, `recruiter_leads`. A preset **only fills options you left at their default** — anything you set yourself wins — and everything it changed (and everything it refused to change) is logged and recorded in `RUN_SUMMARY.preset_applied`. Empty (the default) is a strict no-op. |
| `roleSelect` | string | Optional quick-pick job title. Precedence: `searchTerms` > `roleSelect` > `searchTerm`. Sent to the boards as plain keywords — it is not a fixed taxonomy. Empty = use `searchTerm`. |
| `citySelect` | string | Optional quick-pick metro. Precedence: `locations` > `citySelect` > `location`. Picking a non-US metro also sets `countryIndeed` to the matching country unless you set that field yourself. Empty = use `location`. |
| `searchUrls` | array | Paste LinkedIn / Indeed / Glassdoor search URLs and skip the form. Replaces `searchTerm`, `locations` and `sites` when set. See the table of honored parameters below. |
| `atsCompanies` | array | **Watch companies:** for each name the actor finds that company's own careers board (Greenhouse, Lever, Ashby, Recruitee, BambooHR or Workable — all keyless, tried in that order; see *Can I scrape a specific company's careers page?* below) and merges its live openings into the same 54-column table. Leave the search term at its default and you get each company's whole board; set your own term and only matching roles are delivered and billed. Capped at 20 companies per run. |
| `onlyNewJobs` | boolean | **Monitoring mode:** remembers every job this exact search has already delivered (in a private named store in your own account) and delivers — and bills — only postings it has not seen before. Identical reruns bill zero (measured: 15 rows, then 0). Memory is kept per search; a posting still live is re-stamped on every run that delivers rows (the memory is only written when something new lands), and the 90-day prune runs at that same write, so it only forgets postings that disappeared — long-lived postings are never re-billed. If the memory cannot be read, the run stops and charges nothing rather than re-deliver the baseline. The memory key is terms + locations + boards (+ remote / hoursOld / atsCompanies when set); post-scrape filters such as `excludeTitleKeywords` / `requireTitleKeywords` are not part of it, so toggling them never re-baselines a watch. |
| `webhookUrl` | string | Optional alert webhook: when rows are delivered, POST a digest to this URL — Slack incoming webhook or Discord webhook gets a message, any other URL (n8n / Make / Zapier catch hook) gets JSON with counts, console links and the first 20 rows. Quiet runs send nothing. See **Alerts** below. |
| `searchTerm` | string | Job title / keywords (e.g. `software engineer`). |
| `searchTerms` | array | Multiple searches in one run (merged + deduped); overrides `searchTerm`. Capped at 5 — extras are reported in the run status, never dropped silently. |
| `location` | string | City / state / country (e.g. `New York, NY`). Empty = anywhere. |
| `locations` | array | Several locations in one run; every search term is run against every location. Replaces `location`. Capped at 10 locations and 50 term × location searches; caps are reported in `RUN_SUMMARY`. |
| `sites` | array | Boards to scrape. Defaults to `linkedin`, `indeed`, `glassdoor`, `muse`; the other keyless boards (`remotive`, `jobicy`, `himalayas`, `hn_hiring`, `remoteok`, `weworkremotely`, `working_nomads`, `devitjobs_us`, `devitjobs_uk`) are opt-in — the seven remote-only ones auto-join when **Remote jobs only** is on and this list is left at its default. Unrecognised names are reported, not silently ignored. |
| `maxResults` | integer | Job postings requested per board (1–500, clamped). `0` and negatives clamp to 1 rather than falling back to the default. |
| `isRemote` | boolean | Asks each board for **its own** remote filter, then drops any row whose own `is_remote` is `false` before billing — LinkedIn accepts `f_WT` and ignores it, so the board-side filter alone is not enough. Rows with no description carry no evidence and are kept. With the board list left at its default it also auto-adds the seven remote-only boards, which **adds rows and raises the bill**. |
| `jobType` | string | Full-time / part-time / internship / contract. Only Indeed and Glassdoor filter this board-side (LinkedIn and the keyless API boards ignore it), so rows whose own `job_type` contradicts your choice are dropped before billing. A posting with no `job_type` at all is kept. |
| `hoursOld` | integer | Only jobs posted in the last N hours (board-side filter). Above 8760 (one year) the age limit is dropped and your other board-side filters are kept — it does **not** silently strip easy-apply/job-type/remote the way any other non-zero value must. Any board-side filter a board refuses to combine with it is dropped for that board only and named in `RUN_SUMMARY.board_filters_dropped`. |
| `maxAgeDays` | integer | Drop postings older than N days. Applied by the actor after scraping, so it works on every board; filtered rows are never billed. |
| `requireSalary` | boolean | Keep only rows carrying a salary figure. Filtered rows are never billed. |
| `excludeTitleKeywords` | array | Drop jobs whose title contains any of these words/phrases (case-insensitive). Filtered rows are never billed. |
| `requireTitleKeywords` | array | Keep **only** jobs whose title contains at least one of these words/phrases (case-insensitive substring, title only) — the mirror of `excludeTitleKeywords`, for boards that match your search term against descriptions. Filtered rows are never billed, and the `onlyNewJobs` memory is untouched either way. |
| `excludeCompanies` | array | Drop these companies. Names are normalized first, so `Wipro` also excludes `Wipro Limited`. Filtered rows are never billed. |
| `targetCompanies` | array | Keep **only** these employers (competitor hiring watch). Same normalization, plus a parent match: `Amazon` keeps `Amazon Web Services` but not `Amazonia`. Filtered rows are never billed. |
| `experienceLevel` | array | `internship` / `entry` / `associate` / `mid_senior` / `director` / `executive`. Best-effort — see the note below. Filtered rows are never billed. |
| `excludeCities` | array | Drop jobs whose location mentions any of these places (whole-word, case-insensitive). Filtered rows are never billed. |
| `excludeAgencies` | boolean | Drop rows whose **company name** looks like a staffing agency or recruiter. Heuristic; see the note below. Filtered rows are never billed. |
| `strictKeywordMatch` | boolean | Keep only rows whose title or description contains your search words. Off by default. Use it for narrow roles — LinkedIn never answers "nothing matched" and returns loosely related postings instead. Filtered rows are never billed. |
| `resumeKeywords` | array | Score every row against your skills instead of the default search-term scoring — `matched_keywords` and `keyword_match_percent` are filled either way. Never filters, never changes the bill. |
| `sortBy` | string | `relevance`, `date_desc` or `salary_desc`. Applied before the charge cap. |
| `countryIndeed` | string | Country for Indeed & Glassdoor (e.g. `usa`, `uk`, `india`). Leave it alone and a location naming a country (`Berlin, Germany`) points Indeed at that country's site automatically. A spelling we don't recognise falls back to `usa` with a note in `RUN_SUMMARY` — it never fails the run. |
| `distance` | integer | Search radius in miles. LinkedIn and Indeed honor it; Glassdoor and the API boards ignore it. |
| `offset` | integer | Skip the first N job postings per board, to page past a run you already have. LinkedIn, Indeed and Glassdoor page server-side; on the keyless API boards the Actor fetches N extra rows and hands you the tail. Boards order results their own way, so it is not a stable cursor. |
| `easyApply` | boolean | LinkedIn/Indeed direct-apply jobs only. It **cannot be combined with `hoursOld`**: when both are set it is dropped for LinkedIn and Indeed, the run log says so and `RUN_SUMMARY.board_filters_dropped` records it. Unlike `jobType` / `isRemote`, which Indeed also drops under `hoursOld` but the actor then enforces after scraping, there is no post-scrape easy-apply check — the rows come back unfiltered. |
| `linkedinFetchDescription` | boolean | **On by default.** Fetches each LinkedIn job's own page — the source of LinkedIn descriptions, job type and industry. Turn off for faster runs. |
| `descriptionFormat` | string | `markdown` or `html`. |
| `descriptionHtml` | boolean | Also emit `description_html` (the board's original HTML) alongside `description`. Both in one run, no extra requests. |
| `includeCompanyDetails` | boolean | Kept for compatibility — since v0.1.48 the company block (including `company_ceo`, `company_banner`, `company_addresses_all` and `company_details_filled`) ships on every run automatically, so this toggle changes nothing. No extra requests either way. |
| `proxyConfiguration` | object | Proxy settings. Datacenter is the default and what you want (measured 2026-08-11: 96% of the rows at ~1/5 the cost); switch to Residential only if a board starts coming back short. |

**Example input:**

```json
{
  "searchTerm": "data analyst",
  "location": "Austin, TX",
  "sites": ["linkedin", "indeed", "glassdoor"],
  "maxResults": 50,
  "hoursOld": 168,
  "requireSalary": true,
  "excludeTitleKeywords": ["senior", "intern"]
}
```

**Paste-a-URL input:**

```json
{
  "searchUrls": [
    "https://www.indeed.com/jobs?q=python+developer&l=Austin%2C+TX&fromage=14&radius=25",
    "https://www.linkedin.com/jobs/search?keywords=python%20developer&location=Austin%2C%20Texas&f_TPR=r604800"
  ],
  "maxResults": 25,
  "resumeKeywords": ["Python", "AWS", "Kubernetes"],
  "sortBy": "relevance"
}
```

**Multi-city + competitor watch input:**

```json
{
  "searchTerms": ["data analyst", "business analyst"],
  "locations": ["Austin, TX", "Chicago, IL", "Denver, CO"],
  "maxResults": 30,
  "targetCompanies": ["Deloitte", "Accenture", "Capgemini"],
  "includeCompanyDetails": true,
  "sortBy": "date_desc"
}
```

#### Can I scrape a specific company's careers page?

Yes. List the company names in **Watch companies** (`atsCompanies`) and the Actor reads each one's own careers API directly — Greenhouse, Lever, Ashby, Recruitee, BambooHR or Workable, all keyless — and merges the live openings into the same 54-column table, up to 20 companies per run.

A job board is a search; a company's careers board is an inventory. For every name in `atsCompanies` the actor turns it into a slug (`"Stripe Inc."` → `stripeinc`), asks each ATS below for that slug **in this order**, and the first one that answers with postings wins — a company lives on one ATS. A company on none of them is named in the log and `RUN_SUMMARY` and costs nothing. `maxResults` caps the rows **per company**, newest first. Up to 20 companies per run.

Recruitee, BambooHR and Workable were added on 2026-08-29 and verified from a residential IP rather than the Apify datacenter pool; Greenhouse, Lever and Ashby came first. A company on none of the six is named in the log and RUN\_SUMMARY and costs nothing.

| ATS | What is read | Posting date | Description |
|---|---|---|---|
| **Greenhouse** | `boards-api.greenhouse.io/v1/boards/{slug}/jobs` | `first_published` (`updated_at` ships separately as `date_updated`) | in the feed; a placeholder location label (`N/A`, `TBD`) ships as null, not as a place |
| **Lever** | `api.lever.co/v0/postings/{slug}` | `createdAt` | in the feed |
| **Ashby** | `api.ashbyhq.com/posting-api/job-board/{slug}` | `publishedAt` | in the feed; pay range where the company publishes one |
| **Recruitee** | `{slug}.recruitee.com/api/offers/` | `published_at` | in the feed (description + requirements); salary only when the company publishes it |
| **BambooHR** | `{slug}.bamboohr.com/careers/list` | on each posting's detail page only | on each posting's detail page only |
| **Workable** | `apply.workable.com/api/v3/accounts/{slug}/jobs` | `published` | on each posting's detail page only |

The three added on 2026-08-29 (Recruitee, BambooHR, Workable) were verified from a residential IP, not from the Apify datacenter pool — if Workable's edge walls datacenter exits, that company simply reads "not found" and costs nothing. BambooHR's list carries **no date and no description**, and Workable's carries no description: those come from one extra request per posting, opened newest-first for up to `min(40, 3 × maxResults)` postings per company while **Full LinkedIn job details** is on (its default). Postings past that cap still ship — with `date_posted` null (BambooHR) or `description` null — and undated rows pass the freshness filters, because staleness cannot be proven. Workable boards larger than one page have not been measured; the actor reads what one call returns.

**Company watch on a schedule** — the input that pays for itself:

```json
{"atsCompanies": ["Stripe", "bunq", "Softheon"], "sites": [], "onlyNewJobs": true, "maxResults": 100, "webhookUrl": "https://hooks.slack.com/..."}
```

`sites: []` skips the job-board search entirely (ATS-only run); leave `searchTerm` untouched for the whole board, or set one to keep only matching titles and descriptions. Save, then **Actions → Schedule** (daily is plenty — these boards change a few times a week). The first run seeds the memory; every later run delivers and bills only postings not seen before. The memory key is each posting's own URL, so a posting the company re-saves is not re-billed, and the search's memory key is built from the company names you typed — adding the three new providers changed nothing for schedules that already exist.

#### Can I paste a LinkedIn, Indeed or Glassdoor search URL instead of filling in the form?

Yes. Paste it into `searchUrls` and it replaces `searchTerm`, `locations` and `sites` for the run — LinkedIn, Indeed (any country sub-domain) and Glassdoor URL shapes are accepted, and each URL scrapes only the board it came from.

Build the search on the board's own site, copy the URL, paste it in. A LinkedIn URL runs LinkedIn only. Anything the URL contains that is **not** in this table (`geoId`, tracking ids, `currentJobId`, saved-search ids) is ignored.

Verified 2026-08-08: an Indeed URL and the equivalent manual inputs returned the same 10 job ids and the same output columns, so the URL is a front door onto the same pipeline, and a Glassdoor slug URL returned 8 rows while a keyword-less glassdoor.com/Job/jobs.htm URL was rejected with a readable reason.

| Board | URL shapes accepted | Parameters honored |
|---|---|---|
| **LinkedIn** | `linkedin.com/jobs/search?keywords=…` | `keywords` → search term, `location`, `distance` (miles), `f_WT=2` → remote only, `f_TPR=r<seconds>` → posted-within, `f_JT` → job type, `f_E` → experience level, `f_C` → company ids, `sortBy` (`DD`/`R`), `start` → offset. **Caveat (measured):** `f_WT` and `f_JT` are read and forwarded, but LinkedIn's guest search ignores both, and a pasted URL by itself does not switch on the post-scrape remote / job-type gate — set **Remote jobs only** / **Job type** in the form next to the URL and the contradicting rows are dropped before billing |
| **Indeed** | `indeed.com/jobs?q=…` (any country sub-domain, e.g. `ca.`, `uk.`, `de.`) | `q` → search term, `l` → location, `radius`, `fromage` (days) → posted-within, `sc` `attr(DSQF7)` → remote, `sc` `jt(…)` → job type, `explvl` → experience level, `sort=date`, `start` → offset, and the country from the sub-domain |
| **Glassdoor** | `glassdoor.com/Job/…-SRCH_IL.<a>,<b>_IC…_KO<c>,<d>.htm` **or** `…/Job/jobs.htm?sc.keyword=…` | `sc.keyword` / `keyword` (or the `KO` slug offsets) → search term, `locKeyword` / `locName` (or the `IL` slug offsets) → location, `fromAge` (days), `remoteWorkType=1` → remote, `jobTypes`, `seniorityType` → experience level, `sortBy`, and the country from the domain suffix. **`radius` is not read** — Glassdoor's scraper has no distance parameter, so a radius in the URL changes nothing (only LinkedIn and Indeed honor `distance`) |

A URL we cannot parse is **skipped with a reason**, never guessed at: the other URLs still run and `RUN_SUMMARY.search_url_errors` names the URL and what was missing. If none of them parse, the run scrapes nothing and bills nothing.

Each URL's filters stay on that URL. A seniority filter carried in one URL (`f_E`, `explvl`, `seniorityType`) is applied **only to the rows that URL returned** — pasting an executive-only LinkedIn URL next to an unfiltered Indeed URL does not thin out the Indeed results. Those rows are removed before de-duplication and therefore before billing, and the count shows up in `RUN_SUMMARY.filter_removed` as `experienceLevel (from search URL)`, with the per-URL levels in `RUN_SUMMARY.search_url_experience_levels`. Setting the `experienceLevel` input explicitly overrides every URL's own level filter.

Verified locally on 2026-08-08: an Indeed URL and the equivalent manual inputs (`searchTerm` + `location` + `hoursOld: 336` + `distance: 25`) returned **the same 10 job ids and the same output columns** — the URL path is a front door onto the same pipeline, not a different one. A Glassdoor `SRCH_…KO…` slug URL returned 8 rows; `glassdoor.com/Job/jobs.htm` (no keyword anywhere) was rejected with a readable reason while the other URL still ran.

#### Keyword × location matrix (`locations`)

`locations` runs **every search term against every location** and merges the results into one deduplicated table. It is the way past a board's per-query cap: one query for a whole country returns one capped page, five city queries return five. Every pair asks each board for `maxResults` rows, so cost scales with the number of pairs — capped at 10 locations and 50 pairs per run, and both caps are reported (never applied silently). Rows carry `matched_search_term` and, whenever a run covers more than one location, `matched_location`.

Live run (2026-08-08, `data analyst` × \[`Austin, TX`, `Chicago, IL`] × LinkedIn + Indeed + Glassdoor at 12/board): 72 raw rows → 67 after cross-search dedup → 63 billed after filters, split 28 Austin / 35 Chicago.

#### Post-scrape filters — filtered rows are never billed

These filters run inside the actor after scraping and deduplication and **before billing**, so a filtered row is never charged. `RUN_SUMMARY.filter_removed` records how many rows each one took.

- `requireSalary` — keep only rows carrying a salary figure (`salary_min` or `salary_max`).
- `jobType` — **also enforced here**, not just board-side. Indeed and Glassdoor filter employment type themselves; LinkedIn's guest search accepts the parameter and returns the same rows anyway, and the keyless API boards have no such filter at all. So a row whose own `job_type` contradicts your choice is dropped before billing. A posting the board published with no `job_type` is kept — silence is not a contradiction. Measured 2026-08-08: `jobType: internship` on Remotive + Jobicy + Himalayas dropped all 35 mismatching rows and billed **0**; on LinkedIn + Indeed it dropped LinkedIn's 10 full-time rows and billed 10 rows that all carry `internship`.
- `isRemote` — **also enforced here** for the same reason: LinkedIn ignores `f_WT`. Rows whose own `is_remote` is `false` are dropped before billing; rows with no description carry no evidence and are kept. Measured 2026-08-08 on the *Remote-only sweep* preset: 19 rows dropped, and **0** of the billed rows had `is_remote: false` (before this, 10 of 10 LinkedIn rows billed under that preset were on-site jobs in Midland TX, El Paso TX and Pittsburgh PA).
- `strictKeywordMatch` — opt-in, off by default. Keep only rows whose title or description contains your search words. LinkedIn never answers "nothing matched"; it degrades to loosely related cards. Measured 2026-08-08: `underwater welder` in Fargo, ND billed 10 rows of marine techs, trenchless engineers and welders with it off, and **0** with it on. Either way `RUN_SUMMARY.rows_not_mentioning_search_term` counts the off-topic rows per board (that run: `{"linkedin": 10}`), so you can see the problem before deciding to pay for it.
- `excludeTitleKeywords` — drop titles containing any listed word or phrase (case-insensitive substring match).
- `requireTitleKeywords` — the mirror: keep only titles containing **at least one** listed word or phrase (case-insensitive substring match, title only). Built for the *Software Engineering Intern* problem, where a board matches the search term against descriptions and returns full-time roles that merely mention interns; `RUN_SUMMARY.filter_removed.requireTitleKeywords` counts what it removed. Neither title filter is part of the `onlyNewJobs` memory key — they only remove rows before delivery, and undelivered rows are never marked seen — so adding one to a scheduled watch never re-delivers or re-bills the baseline.
- `excludeCompanies` — drop listed companies; names are normalized before matching, so `Wipro` also excludes `Wipro Limited`.
- `targetCompanies` — the inverse: keep **only** the listed employers. Same normalization plus a parent match, so `Amazon` keeps `Amazon Web Services` while leaving `Amazonia` out. This is the competitor-hiring-watch input.
- `maxAgeDays` — drop postings older than N days. Unlike `hoursOld` this is applied by the actor itself, so it works on every board.
- `excludeCities` — drop jobs whose location text mentions a listed place. Whole-word and case-insensitive, matched against the location exactly as the board published it — `Dallas` drops `Dallas, TX`, `Dall` drops nothing. Best-effort: boards format locations inconsistently.
- `excludeAgencies` — drop rows whose **company name** matches a staffing/recruiting pattern (`staffing`, `recruit*`, `headhunt*`, `talent*`, `manpower`, `personnel`, `placement*`, `consultanc*`, `resourcing`, `workforce`, `temp agency`, `employment agency`, `search partners/group/associates`, `HR solutions`, `staff augmentation`). It reads the name only, so a re-poster with a neutral name gets through and a genuine employer with `Workforce` in its name gets dropped — use `excludeCompanies` when you need exact control.
- `experienceLevel` — **best-effort, and here is exactly how.** When a board publishes a seniority field we use it (LinkedIn does with detail fetching on — measured 19/19 LinkedIn rows; Jobicy, Himalayas and The Muse also publish one). Otherwise we read it off the title: `Senior` / `Sr.` / `Staff` / `Principal` / `Lead` → mid-senior, `Junior` / `Jr.` / `Graduate` / `Entry level` → entry, `Intern` → internship, `Director` → director, `VP` / `Chief` / `Head of` → executive. **A job whose level we cannot determine is kept, not dropped** — we only remove rows we can prove don't match. On a live 63-row run the level came from a board field on 19 rows, from the title on 16, and was genuinely unknown on 28.

Verified on live runs: `requireSalary` removed 7 rows and `excludeTitleKeywords` 3 (2026-08-07); `excludeAgencies` removed 4 rows and `experienceLevel` 1 (2026-08-08), all recorded in `RUN_SUMMARY.filter_removed`, and a `targetCompanies` value matching nothing ended the run with **0 rows pushed and nothing billed**. `excludeAgencies` reads the **company name only**, so treat it as a heuristic rather than a measurement: across the 178 distinct employers returned by the 2026-08-08 local runs it flagged none, which tells you how few agency reposts a given search contains, not that it is precise. Its known failure mode is the `workforce` token — `Texas Workforce Commission` and `Workforce Software` are both flagged, and both are genuine employers posting their own jobs. Use `excludeCompanies` when you need exact control.

#### Keyword scoring (`matched_keywords` / `keyword_match_percent`) and `sortBy`

Every row is scored for keyword fit, at no extra request and no extra cost:

- **With `resumeKeywords` set** (`Python`, `Kubernetes`, `SOC 2`, `C++`, `.NET`, `node.js` — case-insensitive substring, so punctuation works as written): `matched_keywords` lists which of your keywords appear in the title or description, in your order, and `keyword_match_percent` is the share of them matched (0–100). Measured on a 63-row live run: 8 rows at 100%, 17 at 75%, 15 at 50%, 8 at 25%, 15 at 0%.
- **Without it** (the default), the same two columns score the row against **the run's own search terms**, using the same tokenizer the actor's keyword gate uses — so `keyword_match_percent` and `RUN_SUMMARY.rows_not_mentioning_search_term` can never disagree about what "mentions the search term" means. `matched_keywords` lists which words of your best-matching term the row actually mentions (empty at 0%), and a search term that is all stopwords (`remote jobs`) leaves both columns null, because there is nothing to score against. `RUN_SUMMARY.keyword_match_scored_against` names which set scored the run.

Honesty note: until v0.1.48 (2026-08-22) these columns filled **only** with `resumeKeywords`, which no run had ever set — so they were null on 100% of the 5,936 rows this actor had ever delivered, while the actor computed the same match internally and threw it away. Back-tested by re-scoring those 5,936 delivered rows with the shipped function: 89.4% score 100, 8.9% partial, 1.8% score 0 — the 0% bucket being exactly the board drift the score exists to expose ("Full Stack Developer" billed for a "software engineer" search).

Scoring is **annotation only**: no row is removed and the bill is unchanged. `sortBy: relevance` ranks by match percent, then by how many boards the job was found on, then by date. `sortBy: date_desc` and `salary_desc` sort on posting date and on the annualized salary; rows with no value for the key always land last, and sorting runs **before** the charge cap so a truncated run keeps your top rows.

***

### Output fields and how often they are filled

#### Every row has the same 54 columns

**One column tuple per run — and the same tuple in every run.** Every row the Actor delivers carries all 54 columns below, in the same order, whatever board it came from and whatever options you set. A field the board did not publish is `null`, never missing. That is what makes the CSV safe to open in a spreadsheet, `pd.read_csv` without `dtype` surprises, and parse by column position.

This changed on 2026-08-08. Rows used to have their empty fields stripped out before delivery, so a column existed on the rows one board filled in and silently vanished from the rest — a LinkedIn + Indeed + Glassdoor run of this shape once shipped **10 different column tuples ranging from 20 to 31 columns**. Since the fix, **every row of a run carries the same column tuple**, however many rows come back and whatever boards fill them. A live default LinkedIn + Indeed + Glassdoor run delivered one tuple of **50 columns on every row** (the tuple of the day — four more were appended at the end on 2026-08-29, see below) — the row count is just whatever the boards return that day (57 the first time this was measured, 60 on the latest run) — and separate runs across the five API boards and an Indeed-only run with every option switched on carried that same tuple in the same order.

The column order, which is stable and safe to depend on:

`id`, `title`, `company`, `location`, `job_url`, `job_url_direct`, `site`, `job_type`, `is_remote`, `date_posted`, `salary_source`, `salary_min`, `salary_max`, `salary_currency`, `salary_interval`, `salary_min_annual`, `salary_max_annual`, `job_level`, `job_function`, `listing_type`, `company_industry`, `company_url`, `company_url_direct`, `company_addresses`, `company_description`, `company_num_employees`, `company_revenue`, `company_rating`, `company_reviews_count`, `company_logo`, `description`, `emails`, `skills`, `experience_range`, `vacancy_count`, `work_from_home_type`, `found_on_sites`, `duplicate_count`, `search_term`, `matched_search_term`, `scraped_at`, `description_html`, `company_ceo`, `company_banner`, `company_addresses_all`, `company_details_filled`, `matched_keywords`, `keyword_match_percent`, `matched_location`, `source_search_url`, `date_updated`, `applicants`, `applicants_is_bound`, `salary_text`.

The last four were **appended on 2026-08-29** (at the end, so the first 50 positions are exactly what they were). `date_updated` is Greenhouse's `updated_at` — the board bumps it when a posting is re-saved without changing, so it is no longer used as `date_posted` (that now reads `first_published`); null on every other board. `applicants` is LinkedIn's applicant caption read off the job page the run already fetches when *Full LinkedIn job details* is on (the default), and `applicants_is_bound` is `true` when that figure is a bound rather than a count ("Over 200 applicants", "Be among the first 25 applicants"); both null on other boards, when the page shows no caption, or with detail fetching off. No extra request for any of the three. `salary_text`, appended the same day, is the four salary columns rendered as one readable string (`$158,100–$213,800 / year`, `$45 / hour`); it is derived from the row's own `salary_min` / `salary_max` / `salary_currency` / `salary_interval` and is `""` when the board published no amount — see *How to read the output*.

The last nine are **feature-driven**, and since v0.1.48 most fill without any toggle. `company_ceo`, `company_banner`, `company_addresses_all` and `company_details_filled` fill automatically on the rows Indeed published them for (`includeCompanyDetails` is kept for compatibility and changes nothing). `matched_keywords` / `keyword_match_percent` fill on every scorable row — against your `resumeKeywords` when given, otherwise against the run's own search terms (null only when every search term is stopwords, e.g. `remote jobs`). Two columns are genuinely **conditional**: `description_html` fills only with `descriptionHtml: true` and `source_search_url` only on a `searchUrls` run — null there means **"you did not use that feature"**, never "we could not get it". `matched_location` fills only when a run covers more than one location, for the same reason. The **column is there either way**. A column that appears and disappears with a setting is the same ragged-CSV problem in miniature, so we don't do that. New fields are only ever appended to the end of this list, never inserted, so a positional parser keeps working.

Two things that can still hand you a narrower row, both of them your own choice: `?fields=` / `?omit=` on the Dataset API, and `clean=true` (a shortcut for `skipHidden=true` + `skipEmpty=true`), which strips empty values back out. Plain `format=csv`, `format=xlsx`, `format=json` and the Console **Export** button all give you the full 54.

#### How often is each field actually filled?

`title`, `site`, `job_url`, `is_remote`, `date_posted`, `found_on_sites` and `duplicate_count` are filled on 100% of rows; `company` on 95-98% and `location` on 98-100%; `description` on 100% of rows with LinkedIn detail fetching on (the default) and 64% with it off; a salary figure on 59-70%. The rest depends on which board the row came from, and the table below gives the measured rate for every column.

Job boards publish wildly different amounts of detail, so most fields are **conditional** — the column is always present, but how often it carries a value depends entirely on the board. These are **measured** fill rates, not aspirations — taken from live runs on 2026-08-07 (58–85 rows for the detail-off column, 67–72 rows with LinkedIn detail fetching on). Ranges span two independent runs on different queries; expect your own numbers to land inside them. Rows marked **all-time** were re-measured 2026-08-23 across **every row this actor has ever delivered** (5,936 rows, 83 non-empty runs) — for single-board fields that is the honest scope, because the per-run percentage is really just that board's share of that day's run.

**LinkedIn detail fetching is now on by default**, so the right-hand column is what a default run delivers — a live default run (`{}`) returned 60 rows with descriptions on **20 of 20** LinkedIn rows. The left column is what you fall back to if you turn it off for speed.

| Field | Detail fetch off | Detail fetch on (default) | Comes from |
|---|---|---|---|
| `id`, `title`, `site`, `job_url`, `is_remote`, `date_posted`, `scraped_at`, `search_term`, `matched_search_term`, `found_on_sites`, `duplicate_count` | **100%** | **100%** | all boards |
| `company` | 98% | 95% | all boards |
| `location` | 98% | 100% | all boards |
| `company_url` | 97% | 94% | all boards |
| `description` | 64% | **100%** | Indeed + Glassdoor always; LinkedIn only with detail fetching |
| `salary_min`, `salary_max`, `salary_interval`, `salary_currency`, `salary_source` | 56–60% | 59–70% | Glassdoor 95–100%, Indeed 50–80% (varies by query), LinkedIn none without detail fetching and ~35% with it |
| `company_logo` | 49% | 86% | Glassdoor + Indeed; LinkedIn with detail fetching |
| `listing_type` | 11.4% all-time | 11.4% all-time | **Glassdoor only** — 100% of Glassdoor rows (676 of 676 ever delivered), value always `sponsored` or `organic`. The dataset-wide rate is just Glassdoor's share of a run; an earlier version of this table said 32%, measured on two Glassdoor-heavy runs |
| `job_url_direct` | 31% | 33% | Indeed only (the employer's own posting URL) |
| `company_rating` | 10.1% all-time | 10.1% all-time | **Glassdoor only** — 88.5% of Glassdoor rows (598 of 676 ever delivered; Glassdoor sends no rating for unrated employers). An earlier version of this table said 29–30%, measured on two Glassdoor-heavy runs |
| `job_type` | 24% | **65%** | Indeed ~80%; LinkedIn 100% *only* with detail fetching; never from Glassdoor |
| `company_url_direct`, `company_addresses`, `company_num_employees`, `company_revenue`, `company_description` | 17–20% | 19–23% | **Indeed only** (~55–70% of Indeed rows) |
| `job_level`, `job_function` | 0% | 34% | **LinkedIn only, and only with detail fetching** (then 100%) |
| `company_industry` | 2% | 38% | LinkedIn with detail fetching (100% of LinkedIn rows); Indeed ~10% |
| `emails` | 5–8% | 12–14% | scraped out of description text when a job lists one |
| `applicants`, `applicants_is_bound` *(appended 2026-08-29)* | 0% | varies by category/board | **LinkedIn only, and only with detail fetching** — filled when the job page shows an applicant caption; not yet measured on a live run |
| `date_updated` *(appended 2026-08-29)* | varies by category/board | varies by category/board | **Greenhouse (`atsCompanies`) only** — its `updated_at`; null on every other board |

**`linkedinFetchDescription` is on by default**, so `job_type`, `company_industry`, `job_level` and `job_function` arrive without touching anything. Turn it off and LinkedIn contributes none of them — that trade is yours to make for speed.

Two derived columns normalize pay so every row sorts on one axis: **`salary_min_annual` / `salary_max_annual`** convert hourly (×2080), weekly (×52) and monthly (×12) figures to annual. In a live default run they were present on **57 of 57** rows that carried a salary. Two guards keep them honest. When `enforceAnnualSalary` is on, the scraping library annualizes description-parsed pay itself but leaves `salary_interval` reading `hourly`/`monthly` — we rewrite that interval to `yearly` instead of multiplying a second time, which is what used to ship `salary_min_annual: 95180800` on a warehouse job and print `$45,760 / hourly` in the raw columns. And a result that cannot be a wage is left null rather than published; the raw `salary_min` / `salary_max` are never touched. That backstop is applied only to USD-scale currencies, so a real 3,000,000 KRW or 10,000,000 IDR monthly salary still annualizes normally. Measured 2026-08-08 on the `salary_research` preset (`warehouse associate`, Dallas TX, Indeed + LinkedIn): **0 of 14** billed rows carried an impossible annual figure and **0** claimed an hourly rate above $1,000/hour — the same run shape previously produced 9 of 13. The three salary qualifier columns (`salary_interval`, `salary_currency`, `salary_source`) are blank on any row without an actual amount — a qualifier with nothing to qualify is noise. Verified on the 2026-08-08 runs: 0 of 57 and 0 of 69 rows carried an orphan qualifier.

Three columns are **genuinely unavailable upstream**: `experience_range`, `vacancy_count` and `company_reviews_count` are only ever published by Naukri, which is CAPTCHA-blocked — measured 0 of the 5,936 rows this actor has ever delivered. They stay in the row for parser compatibility only; **expect them to be blank** and do not plan a project around them. `skills` is not in that group: Remote OK, DevITjobs US/UK and Working Nomads publish it on 100% of their rows (103 of 5,936 rows ever delivered, 1.7% — the dataset-wide rate is just those boards' share of your run; on the default boards it is blank). And `work_from_home_type` left the blank list on v0.1.48: Indeed publishes the work arrangement in a response the actor already fetches, so Indeed rows now carry `Remote` / `Hybrid` / `Work from office` where the posting was labelled — measured live on 600 Indeed listings: 27.8% carry a label (14.5% Remote, 12.7% Hybrid, 0.7% In person); unlabelled postings stay null rather than guessed.

#### The company block — what you actually get, per board

Since v0.1.48 this block ships on **every run**: `includeCompanyDetails` is accepted for compatibility and changes nothing (the run log says so when you set it). Measured 2026-08-22: the toggle was on in 0 of this actor's 85 real runs, so `company_ceo`, `company_banner`, `company_addresses_all` and `company_details_filled` had been null on 100% of rows ever delivered — while the values were being fetched from every Indeed response and thrown away. Nothing is invented: `company_details_filled` counts how many company fields the row actually got, and the three Indeed extras (**`company_ceo`**, **`company_banner`**, **`company_addresses_all`** — the full address list; `company_addresses` only ever held the first) are fields Indeed already sends and the underlying library discards. No extra requests, no extra cost per row.

Measured on a live 63-row run (2026-08-08, LinkedIn + Indeed + Glassdoor, LinkedIn detail fetching on):

| Field | Indeed rows | LinkedIn rows | Glassdoor rows | Dataset-wide |
|---|---|---|---|---|
| `company_url` | 96% | 100% | 100% | 98% |
| `company_logo` | 71% | 100% | 90% | 86% |
| `company_industry` | 33% | **100%** | 0% | 43% |
| `company_num_employees` (size band) | **75%** | 0% | 0% | 29% |
| `company_url_direct` (employer's own site) | **75%** | 0% | 0% | 29% |
| `company_addresses` / `company_addresses_all` | **71%** | 0% | 0% | 27% |
| `company_description` | **67%** | 0% | 0% | 25% |
| `company_revenue` | **58%** | 0% | 0% | 22% |
| `company_ceo` *(new)* | **54%** | 0% | 0% | 21% |
| `company_banner` *(new)* | **54%** | 0% | 0% | 21% |
| `company_rating` | 0% | 0% | **95%** | 30% |

Read that as: **Indeed is the company-data board** (size, revenue, description, corporate website, addresses, CEO), **LinkedIn contributes the industry** (100% of LinkedIn rows, but only with *Full LinkedIn job details* on — it was 0% in a run with it off), and **Glassdoor contributes the employer rating**. For the five API boards, which board a row came from decides this entirely, so the per-board answer is the only one worth quoting (measured 2026-08-08 on a 34-row remote-only run across all five):

| Column | Jobicy | Himalayas | The Muse | Remotive | HN |
|---|---|---|---|---|---|
| `company_industry` | 10/10 | 0/7 | 0/10 | 0/2 | 0/5 |
| `company_logo` | 10/10 | 5/7 | 0/10 | 2/2 | 0/5 |
| `job_level` | 10/10 | 7/7 | 10/10 | 0/2 | 0/5 |
| `job_function` | 0/10 | 0/7 | 0/10 | 2/2 | 0/5 |

In words: **Jobicy is the only API board that publishes `company_industry`**; Jobicy, Himalayas and Remotive publish `company_logo`; Jobicy, Himalayas and The Muse publish `job_level`; Remotive is the only one that publishes `job_function`; HN publishes none of them. Everything else on these boards is null. Don't read the dataset-wide percentage as a property of the Actor — it is just each board's share of your particular run, and Jobicy is capped at 100 rows per call. If a board doesn't publish a field, the column stays null and says so; we never fill it from somewhere else.

#### `descriptionHtml` — both formats in one run

With `descriptionHtml: true` every row carries **`description_html`** (exactly what the board published) *alongside* the normal `description` in your chosen `descriptionFormat`. There is no second request and no price change: the boards return HTML anyway, so the Markdown is produced from that same HTML locally. Use the HTML when you need the original links, lists and headings for your own site or ATS; use the Markdown for LLM prompts and spreadsheets. Budget for the size: `description_html` holds the raw HTML that the Markdown `description` is generated *from*, so it is necessarily about as large as the column beside it. On a 19-row A/B run (2026-08-08, same query with the switch off and on) `description_html` was filled on 19/19 rows and ran **111% of the `description` column**, growing the whole dataset **86%**. An earlier run measured +167% / +132%. Plan on roughly doubling your dataset, as the input form says — not the "about 13%" an earlier version of this README claimed.

#### JSON output sample

A **complete, unedited row** from a live Glassdoor scrape (2026-08-08, `software engineer` / New York NY / LinkedIn + Indeed + Glassdoor, description truncated for length). This is the whole row — all columns, nulls included (50 when it was captured; the four columns appended on 2026-08-29 are shown at the end as they arrive today). Every other row in that run, from every board, had exactly these keys in exactly this order:

```json
{
  "id": "gd-1009849012184",
  "title": "Software Development Engineer, ML Systems, Annapurna Labs",
  "company": "Annapurna Labs (U.S.) Inc.",
  "location": "New York, NY",
  "job_url": "https://www.glassdoor.com/job-listing/j?jl=1009849012184",
  "job_url_direct": null,
  "site": "glassdoor",
  "job_type": null,
  "is_remote": false,
  "date_posted": "2026-02-28",
  "salary_source": "direct_data",
  "salary_min": 158100.0,
  "salary_max": 213800.0,
  "salary_currency": "USD",
  "salary_interval": "yearly",
  "salary_min_annual": 158100,
  "salary_max_annual": 213800,
  "job_level": null,
  "job_function": null,
  "listing_type": "organic",
  "company_industry": null,
  "company_url": "https://www.glassdoor.com/Overview/W-EI_IE7470741.htm",
  "company_url_direct": null,
  "company_addresses": null,
  "company_description": null,
  "company_num_employees": null,
  "company_revenue": null,
  "company_rating": 3.7,
  "company_reviews_count": null,
  "company_logo": "https://media.glassdoor.com/sql/7470741/amazon-web-services-squareLogo-1680754591940.png",
  "description": "**DESCRIPTION** About Amazon Annapurna Labs ...",
  "emails": null,
  "skills": null,
  "experience_range": null,
  "vacancy_count": null,
  "work_from_home_type": null,
  "found_on_sites": "glassdoor",
  "duplicate_count": 1,
  "search_term": "software engineer",
  "matched_search_term": "software engineer",
  "scraped_at": "2026-08-08T15:52:57.101661+00:00",
  "description_html": null,
  "company_ceo": null,
  "company_banner": null,
  "company_addresses_all": null,
  "company_details_filled": null,
  "matched_keywords": null,
  "keyword_match_percent": null,
  "matched_location": null,
  "source_search_url": null,
  "date_updated": null,
  "applicants": null,
  "applicants_is_bound": null,
  "salary_text": "$158,100–$213,800 / year"
}
```

The trailing nulls, read honestly: `description_html` is null because this run did not set `descriptionHtml: true`, and `source_search_url` is null because it did not use `searchUrls` — null in those two means **"you did not use that feature"**, never "we could not get it". The four company extras are null because this is a Glassdoor row and only Indeed publishes them (since v0.1.48 they fill on Indeed rows automatically, no toggle needed). `matched_keywords` / `keyword_match_percent` are null because this row predates v0.1.48 — on the current build this exact row scores `"software, engineer"` / `100.0` against its own search term. `matched_location` fills only on a multi-location run. `date_updated` is null because this is not a Greenhouse row, and `applicants` / `applicants_is_bound` are null because it is not a LinkedIn row. `salary_text` is the row's own four salary columns written out — `""` on a row with no published pay. The columns never move, appear or disappear.

Results render as a sortable table on the Output tab and export to CSV, JSON, or Excel.

#### Example output

A real sample from a live run (`software engineer`, New York NY, three boards):

| title | company | site | company\_rating | salary\_min | job\_url |
|---|---|---|---|---|---|
| Software Engineer, Systems ML - Compilers | Meta | glassdoor | 3.7 | 154003 | https://www.glassdoor.com/job-listing/j?jl=101021… |
| Silicon Software Lead | Normal Computing Corporation | indeed | | | https://www.indeed.com/viewjob?jk=76bcf… |
| AI Modernization Senior Lead Software E… | JPMorganChase | indeed | | 133000 | https://www.indeed.com/viewjob?jk=74806… |
| DevOps Engineer | OnMed | linkedin | | | https://www.linkedin.com/jobs/view/… |

### How to read the output

The Output tab opens on **Overview**; the other views are the same rows with a different column preset. One line each:

- **Overview** — the first read: logo, title, company, location, `salary_text`, remote flag, posted date, source board, match % against your search terms, apply link.
- **Salary** — for comp work: the readable salary next to the raw min / max / currency / period and the yearly-normalized pair that sorts hourly, monthly and yearly on one axis.
- **Cross-board** — the dedup evidence (`found_on_sites`, `duplicate_count`) plus the demand signals LinkedIn and Greenhouse publish (`applicants`, `applicants_is_bound`, `date_updated`).
- **Remote & location** — `is_remote` beside the location and Indeed's `work_from_home_type` (Hybrid / Remote / In person), so you can tell true remote from remote-ish.
- **Company info** — the employer block (industry, size, revenue, rating, reviews, company page, website, address, logo); Indeed fills most of it.
- **Standard table** — the original 16 columns, for anyone who built on the pre-2026-08-29 default view.
- **All columns** — all 54 columns of every row, labeled, in delivery order.

**Derived columns** — appended at the end of every row, always present, `""` when unknown, never invented:

- `salary_text` — `salary_min` / `salary_max` / `salary_currency` / `salary_interval` as one string with the currency symbol, thousands separators and an en dash: `$90,000–$175,000 / year`, `€60,000–€80,000 / year`, `$45 / hour`; `$90,000+ / year` when only a minimum was published, `up to $175,000 / year` when only a maximum. Symbols are used for USD, EUR, GBP, JPY, INR, CAD, AUD and a few more; any other code is written once as a prefix (`CHF 90,000–110,000 / year`). Empty when the board published no amount.
- Dates need no derived column: `date_posted` is already `YYYY-MM-DD`, `date_updated` and `scraped_at` are ISO timestamps.

**Exporting just a view.** The Dataset API accepts the view key (documented `view` parameter of *Get dataset items*): `GET https://api.apify.com/v2/datasets/<datasetId>/items?view=salary&format=csv` (keys: `overview`, `salary`, `dedup`, `remote`, `company`, `standard`, `all`). In the Console, select the view above the table, then **Export**. `?fields=` and `?omit=` still work for any ad-hoc column pick.

**The run's status line** reads the same way every time: `Done — 57 jobs delivered for “software engineer” in New York, NY. <any honest coverage clause — a board that returned nothing, a charge cap, a dropped term> Report: <link>. Happy with the data? A quick review on the Store page helps a lot!` A board that no longer answers from any IP (Google Jobs, ZipRecruiter, Bayt, BDJobs, Naukri) is reported as *discontinued upstream*. The **REPORT** record shows the Overview columns under the same labels, with the headline tiles (jobs delivered, boards contributing, cross-board duplicates merged, share with a salary, remote share, new since last run on a monitor).

***

### Use cases

- **Recruiting & staffing** — pull every open role for a title/location across boards into one pipeline.
- **Job boards & aggregators** — backfill and keep a niche board fresh from multiple sources.
- **Hiring-signal sales** — a role live on three boards at once is a company hiring urgently; `duplicate_count` and `found_on_sites` surface exactly that.
- **Labor-market research** — salary ranges, remote share, and demand by title and location.
- **Personal job hunt** — one deduped feed instead of refreshing three sites.

***

### Use with AI agents & automation

Run from the Apify **MCP** server so AI agents (Claude, ChatGPT, Cursor) can pull jobs as a tool call, schedule runs via **Make**, **n8n**, or **Zapier** to alert on new postings, or sync the dataset to **Google Sheets** for a live jobs dashboard. Clean flat JSON drops into ATS/CRM pipelines with no glue code.

For an agent, the minimal call is `{"searchTerm": "<title>", "location": "<city>"}` — every other field has a working default, the output carries the same 54 columns on every row of every run, and the `RUN_SUMMARY` record in the run's key-value store reports each board's outcome machine-readably. In **n8n**, use the Apify node with actor `flash_scraper/multi-jobboard-scraper` (or an HTTP Request node on the `run-sync-get-dataset-items` endpoint below); in **Make** and **Zapier**, use the Apify app's "Run an Actor" module on a schedule.

### Job board API, job feed API and job market data

Every run is a **job board API** call: send a search term and a location, get one deduplicated row per role back as JSON or CSV. That makes it usable as a **job feed API** behind your own product, and as a source of **job market data** and **employment data** for analysis. Rows carry the posting date and, where a board publishes it, the salary, so a series of scheduled runs becomes a **salary data** set for a role and city. `onlyNewJobs` on a schedule delivers only **new job postings** since the last run, which is the cheapest way to track hiring over time. Nothing here needs an API key from the boards themselves.

### Use it from an AI agent (MCP)

- The Apify MCP server at `https://mcp.apify.com` exposes this actor as a tool named after its Store slug, `flash_scraper/multi-jobboard-scraper` with the `/` rewritten — **`flash_scraper--multi-jobboard-scraper`** in the server's current README (Apify's docs page still shows the older `-slash-` spelling, `flash_scraper-slash-multi-jobboard-scraper`). Add it explicitly with `https://mcp.apify.com?tools=flash_scraper/multi-jobboard-scraper`, or let the agent reach it through the default `call-actor` tool after `fetch-actor-details`; either way the rows land in the run's dataset and the agent reads them with `get-dataset-items`.
- Smallest useful call: `{"atsCompanies": ["bunq"], "sites": [], "maxResults": 5}` — one keyless request to that company's own board, rows in well under 60 s (measured locally 2026-08-29), and at most 5 × $0.005 + $0.00005 ≈ **$0.03**.
- Per-result price, read from the live pricing record on 2026-08-29: **$0.005 per dataset item** on the free plan ($0.0045 Bronze → $0.0035 Diamond), plus $0.00005 per run start; the **Pricing** tab is always authoritative.
- Every row carries the same 54 columns, so an agent can address fields by name on the first call; `RUN_SUMMARY` in the run's key-value store reports each board's outcome machine-readably.
- Per the Apify MCP server docs, an MCP client can pay for Actor runs with x402 (USDC on Base) or a Skyfire payment token instead of an Apify API token.

***

### Alerts: get a Slack / Discord / webhook message when new rows land

Set **`webhookUrl`** and every run that delivers at least one row POSTs a digest there — no polling, no extra automation. A **Slack** incoming webhook gets a text message (one line per job, first 20, plus the dataset link); a **Discord** webhook gets the same as a message; **any other URL** (n8n / Make / Zapier catch hook, your own endpoint) gets JSON: `{actor, delivered, run_url, dataset_url, rows[:20], text}`. Quiet runs send nothing. Pair it with **`onlyNewJobs`** on a schedule and the actor is a job-alert service on its own. Delivery is best-effort: a webhook that fails or answers non-2xx is reported in the run's status message (and in `RUN_SUMMARY.webhook`) and never fails the run.

```json
{
  "searchTerm": "data engineer",
  "location": "Austin, TX",
  "onlyNewJobs": true,
  "webhookUrl": "https://hooks.slack.com/services/T000/B000/XXXX"
}
```

***

### Every run comes with a report

Every run that delivers at least one row also writes a **`REPORT`** record to its key-value store: one self-contained HTML page with the headline numbers (jobs, boards, cross-board duplicates merged, share with a salary), three small charts (jobs per board, top companies, how many boards each job was found on), the first 100 rows in the Overview view's columns under the same labels, and the same honest notes as the run's status message (filters applied, boards that returned nothing, time budget). Find it in the run's **Output** tab under **REPORT**, or follow the `Report:` link at the end of the status message. It is a single file with no scripts and no external assets, so it is safe to forward, attach to an email or screenshot for a client. The dataset stays the source of truth: the report never carries more than the dataset does, and a run that delivered nothing writes no report.

***

### Licensing & attribution — what you may do with the rows

The API boards publish their feeds with strings attached, and those strings pass through to you:

| Board | May you re-post rows on another job board? | Required credit |
|---|---|---|
| The Muse | Yes (attribution requested) | Credit The Muse |
| Remotive | **No — their ToS explicitly forbid it** | Credit Remotive |
| Jobicy | Only with credit, and apply buttons must link to the original job URL | Credit Jobicy + keep the `job_url` links |
| HN "Who is hiring?" | User-authored HN content; link back to the HN item | Credit Hacker News / Algolia |
| Himalayas | Yes, with a link-back | Link to himalayas.app |
| **Remote OK** | **Only with a visible, clickable (dofollow) link back to the row's `job_url` and "Remote OK" named as the source — their terms suspend API access otherwise.** Logo use is forbidden | Dofollow link to remoteok.com + name "Remote OK" |
| We Work Remotely | Their terms carry no republication bar; a link-back is good practice | Link to the `job_url` |
| Working Nomads | No stated bar; link-back is good practice | Link to the `job_url` |
| DevITjobs US/UK | Undocumented public API — treat rows as pointers and keep the `job_url` links. ~99% of rows are partner-syndicated (e.g. from Indeed), so salary figures may be partner-estimated | Keep the `job_url` links |

In plain terms: **Remotive rows are fine for lead-gen, research and your own job hunt, but must not be republished on another job board** (their terms name Jooble, Google Jobs, LinkedIn and the like explicitly). If you build a job site on Jobicy data, keep the `job_url` apply links and credit Jobicy. The `job_url` column already points at each board's original posting, which covers the link-back half of these requirements.

***

### API examples

Same input keys as the Console form, from code:

```javascript
const { ApifyClient } = require('apify-client');
const client = new ApifyClient({ token: 'YOUR_APIFY_TOKEN' });
const run = await client.actor('flash_scraper/multi-jobboard-scraper').call({
    searchTerm: 'data analyst', location: 'Austin, TX', maxResults: 50, onlyNewJobs: true,
});
const { items } = await client.dataset(run.defaultDatasetId).listItems();
```

```python
from apify_client import ApifyClient
client = ApifyClient("YOUR_APIFY_TOKEN")
run = client.actor("flash_scraper/multi-jobboard-scraper").call(run_input={
    "searchTerm": "data analyst", "location": "Austin, TX", "maxResults": 50, "onlyNewJobs": True,
})
items = list(client.dataset(run["defaultDatasetId"]).iterate_items())
```

```bash
curl -X POST "https://api.apify.com/v2/acts/flash_scraper~multi-jobboard-scraper/run-sync-get-dataset-items?token=YOUR_APIFY_TOKEN" \
  -H 'Content-Type: application/json' \
  -d '{"searchTerm":"data analyst","location":"Austin, TX","maxResults":50}'
```

Wire the finished-run **webhook** into n8n, Make or Zapier to land rows in Slack, Sheets or your ATS
automatically - combined with `onlyNewJobs` on a schedule, that is a self-maintaining job feed.

> **Only need LinkedIn, as cheap as possible?** The same publisher's
> [LinkedIn Jobs Scraper](https://apify.com/flash_scraper/linkedin-jobs-scraper) is the single-board
> budget tool - priced in the Store's cheapest tier.

### How much does it cost?

**$0.005 per delivered row, so $5 buys 999 rows** (1,000 × $0.005 minus the single $0.00005 Actor Start event) — read off the live Store pricing record on 2026-08-29. A visitor on Apify's free plan can run it with no card on file: the plan's monthly free usage credit covers several default-size searches, and a run that delivers nothing (an over-tight filter, a blocked board) costs only the $0.00005 start event.

**Pay-per-event.** You are charged one **Result** event per row delivered to your dataset, plus a one-off **Actor Start** event. The per-result rate on this page (plus that one Actor Start event) is the whole price — no platform-usage surcharge is added to your bill (verified 2026-08-29 against the Store pricing record). Because de-duplication happens *before* the push, you pay once for a job found on three boards, not three times — and that holds across a keyword × location matrix too, so the same role returned by two city searches is one billed row. Boards that return nothing cost nothing, rows removed by **any** post-scrape filter (`requireSalary`, `excludeTitleKeywords`, `requireTitleKeywords`, `excludeCompanies`, `targetCompanies`, `experienceLevel`, `excludeCities`, `excludeAgencies`, `maxAgeDays`) are never billed, and failed runs deliver no rows and so bill no results. `descriptionHtml` fills a column that is otherwise blank, `resumeKeywords` swaps the keyword-score columns onto your own list instead of the search terms, and the company block ships on every run — none of them ever adds a row, so none of them costs anything extra. See the Apify Store page for current prices.

Cost note for `locations`: each **term × location** pair is a full search on every selected board, so 3 terms × 3 locations × 3 boards can bill up to 9× a single search before de-duplication. The caps (10 locations, 50 pairs) exist for exactly that reason and are reported in `RUN_SUMMARY`, never applied silently.

If you set a maximum total charge on the run and the scrape exceeds it, the actor delivers as many rows as the budget allows and says so in the run status — it will not spend your budget and then hand back an empty dataset.

### How it compares

Read from Apify's public Store API on **2026-09-05**. Four other multi-board job scrapers: [openclawai/job-board-scraper](https://apify.com/openclawai/job-board-scraper) (2,582 users, 5.0 from 3 reviews, $5/1,000, reaches Google Jobs and ZipRecruiter), [doggo/uk-jobs-board-scraper](https://apify.com/doggo/uk-jobs-board-scraper) (368, 5.0 from 2, $5/1,000 plus a $0.10 run start, UK boards), [khadinakbar/jobs-scraper](https://apify.com/khadinakbar/jobs-scraper) (353, no reviews, $3/1,000) and [vero-api](https://apify.com/vero-api/multi-job-board-scraper-linkedin-indeed-glassdoor-more) (208, 5.0 from 1, $5/1,000, adds Bayt and Naukri). Single-board specialists cost less: [curious\_coder/linkedin-jobs-scraper](https://apify.com/curious_coder/linkedin-jobs-scraper) is $2/1,000 with 148,567 users. Those figures are each Actor's own Store listing and stats, not a test of it.

This Actor (42 users, 5.0 from 3) is $5/1,000 and covers **12 selectable working boards** — 11 of them delivering rows today, since DevITjobs UK was discontinued upstream on 2026-08-29 — with cross-board deduplication and only-new alerts. It **cannot** reach Google Jobs or ZipRecruiter. If those are required, openclawai's is the one to try.

***

### FAQ

#### How do I scrape LinkedIn, Indeed and Glassdoor jobs in one run?

Type a search term and a location, leave the board list at its default, and press **Save & start** — LinkedIn, Indeed, Glassdoor and The Muse are searched in the same run and merged into one 54-column table. An untouched run delivered 60 rows in 27 seconds on the then-default three boards (measured 2026-08-08). Eight more keyless boards can be added under **Job boards**, and `RUN_SUMMARY` names what each board actually returned.

#### Will I pay twice for the same job posted on LinkedIn, Indeed and Glassdoor?

No. A role found on three boards is merged into one row **before** billing, so you are charged for one delivered row instead of three. The surviving row carries `found_on_sites` (every board it appeared on) and `duplicate_count`. Dedup keys on the title plus a normalized company name with legal suffixes stripped, so *Wipro* and *Wipro Limited* collapse together. Within one board, two rows merge only when they are literally the same listing — five genuinely different openings at one employer stay five rows.

Measured 2026-08-08 on data analyst / New York, NY at 30 per board across LinkedIn, Indeed and Glassdoor: 90 raw listings became 89 billed rows in 39 seconds, with 1 duplicate merged across boards (Indeed 30, Glassdoor 30, LinkedIn 29).

#### How much does it cost to scrape 1,000 job postings?

**$5 per 1,000 delivered rows** — $0.005 each, read off the live Store pricing record on 2026-08-29 — plus a one-off $0.00005 per run start. Duplicates merged across boards bill once. Rows removed by a post-scrape filter bill nothing. A run that delivers nothing bills only the $0.00005 start event. The live rate is always on this page's **Pricing** tab.

#### Is there a free way to scrape LinkedIn, Indeed and Glassdoor jobs?

Yes — Apify's free plan needs no card on file, and its monthly usage credit covers several default-size searches. Beyond that the price is $0.005 per delivered row ($5 per 1,000).

At the free-plan rate read from the live pricing record on 2026-08-29, an untouched form (the four default boards at up to 20 rows each) delivers at most 80 deduplicated jobs for at most $0.40, a 500-job sweep costs $2.50, and a run that delivers nothing bills only the $0.00005 start.

#### Does Multi Job Board Scraper need an API key or a login?

No. The Actor never logs in to any board, and every row it delivers is public data. The extra boards are keyless public feeds and the six ATS providers are keyless too. From code you only need your Apify token.

All 12 selectable working boards were checked from Apify's default datacenter pool on 2026-08-07 and re-measured 2026-08-11 with no login and no key (DevITjobs UK has since been discontinued upstream, 2026-08-29, re-probed 2026-09-05, so 11 deliver rows today) (52 of 54 rows versus residential on the same search), and the six ATS providers — Greenhouse, Lever, Ashby, Recruitee, BambooHR and Workable — are keyless public APIs.

#### Which job boards can I scrape without an API key?

Nine keyless public boards ship with this Actor: The Muse, Remotive, Jobicy, Himalayas, Hacker News "Who is hiring?", Remote OK, We Work Remotely, Working Nomads and DevITjobs US/UK. LinkedIn, Indeed and Glassdoor need no key either, because they are read from their public search pages. Five other selectable boards (Google Jobs, ZipRecruiter, Bayt, BDJobs, Naukri) are blocked at the source today — the **Board status** table above says which, and why.

In a verified remote-only run on 2026-08-08 (python developer, 10 requested per board) the five API boards delivered The Muse 10, Jobicy 10, Hacker News 5, Himalayas 6 and Remotive 2 rows; per-board counts track each board's live inventory for the term and move day to day.

#### Where does the data come from?

Public job listings on LinkedIn, Indeed and Glassdoor via the open-source JobSpy engine plus our own fixes on top of it, and the public keyless feeds of The Muse, Remotive, Jobicy, Himalayas, Hacker News (Algolia), Remote OK, We Work Remotely (its category RSS feeds), Working Nomads and DevITjobs US/UK. Company boards come straight from the employer's own ATS API.

Board status was checked 2026-08-07 and re-measured 2026-08-11 from Apify's default datacenter pool. Across the 5,936 rows this Actor had delivered by the 2026-08-23 all-time re-measure (83 non-empty runs), Glassdoor accounted for 676 rows, every one carrying listing\_type (sponsored or organic).

#### Can I run JobSpy without hosting it myself?

Yes. LinkedIn, Indeed and Glassdoor are read through the open-source JobSpy engine, running on Apify with proxies already configured — you POST a search as JSON and get rows back, with no Python environment to maintain. Three fixes ride on top of the library: LinkedIn paging now advances by the page's own cards instead of the running total (so offsets 20-29, 40-59 are no longer skipped above 20 LinkedIn rows), Glassdoor carries patches backported from open JobSpy pull requests #347 and #350, and descriptions are converted to Markdown without the stray backslash escapes (`full\-time`) the library emits by default.

The LinkedIn paging fix shipped on 2026-08-29 (deeper pages no longer repeat and newest-first ordering holds at depth), and the proxy comparison of 2026-08-11 measured 52 rows from the default datacenter pool against 54 from residential on the same search, at roughly a fifth of the cost.

#### How fresh is the data?

Every run reads each board's current listings live, so a row is as fresh as the board was at the moment of the run — there is no cached index and no historical archive. `scraped_at` records when the Actor read the row and `date_posted` is the board's own posting date. Company boards (`atsCompanies`) are the freshest source here, because they are the employer's own ATS feed rather than a board's copy of it.

Two identical runs a day apart shared 47% of their rows (measured before monitoring mode existed), which is what Only new jobs removes from a schedule; the Fresh postings only preset keeps the last 7 days (maxAgeDays: 7 plus a 168-hour board-side window), and since the 2026-08-29 paging fix LinkedIn's newest-first ordering holds beyond 20 rows.

#### How do I run it on a schedule?

Fill in your search, tick **Only new jobs (monitoring mode)**, press **Save & start** once, then on the run page choose **Actions → Schedule** and pick daily (or hourly for hot markets). Every later run delivers, and bills, only postings the previous runs have not seen. The first run seeds the memory and an identical rerun bills zero (measured: 15 rows, then 0). Point the schedule's `webhookUrl` at Slack, at a Google Sheet via Zapier or Make, or at your ATS.

Schedule-grade filters, Slack / Discord / JSON digests and the safe stop before the time limit shipped on 2026-08-25; the memory is kept per search for 90 days in a private store in your own account, and a memory that cannot be read stops the run and charges nothing rather than re-delivering the baseline.

#### How do I get an alert for only new job postings each day?

Turn on **Only new jobs (monitoring mode)**, set a `webhookUrl`, and schedule the run daily. The Actor remembers every posting this exact search has already delivered, in a private store in your own account, and delivers and bills only postings it has not seen before. A run with nothing new delivers no rows, sends no webhook, and bills only the $0.00005 run start. The memory is kept per search for 90 days.

Webhook alerts shipped on 2026-08-25: a Slack or Discord webhook gets one line per job for the first 20 rows plus the dataset link, any other URL gets JSON with counts, console links and the first 20 rows, and a webhook that fails is reported in RUN\_SUMMARY.webhook without failing the run.

#### Can I monitor a specific company's careers page for new roles?

Yes — put the company names in **Watch companies** (`atsCompanies`), turn on **Only new jobs**, and schedule it. The Actor reads each company's own ATS board (Greenhouse, Lever, Ashby, Recruitee, BambooHR or Workable, tried in that order) and delivers only postings that were not there last time. Set `sites: []` to skip the job-board search entirely. Up to 20 companies per run, `maxResults` rows per company, newest first. The memory key is each posting's own URL, so a posting the company re-saves is not re-billed.

Recruitee, BambooHR and Workable joined Greenhouse, Lever and Ashby on 2026-08-29, verified from a residential IP; adding them changed nothing for schedules that already existed, because the watch's memory key is built from the company names you typed. Daily is plenty — these boards change a few times a week.

#### Can I paste a LinkedIn or Indeed job search URL instead of building a query?

Yes — put it in `searchUrls`. LinkedIn, Indeed (any country sub-domain) and Glassdoor are supported, and the exact parameters read out of each URL are listed above. A URL from another site, or one with no keyword in it, is skipped with a written reason in `RUN_SUMMARY.search_url_errors` rather than silently scraping something else.

Checked 2026-08-08: an Indeed URL and the equivalent manual inputs (searchTerm, location, hoursOld: 336, distance: 25) returned the same 10 job ids and the same output columns. Radius is honored by LinkedIn and Indeed only; Glassdoor's scraper has no distance parameter, so a radius in a Glassdoor URL changes nothing.

#### Does it scrape remote jobs?

Yes — **Remote jobs only** asks Indeed, Glassdoor and The Muse for their own remote filters, enforces LinkedIn's `is_remote` after scraping, and auto-adds seven remote-only boards (Remotive, Jobicy, Himalayas, HN "Who is hiring?", Remote OK, We Work Remotely, Working Nomads). LinkedIn's guest search accepts `f_WT` and ignores it, which is why the Actor gates those rows itself, before billing.

Measured 2026-08-08 on the Remote-only sweep preset: 19 rows were dropped before billing and 0 of the billed rows had is\_remote false — before that gate, 10 of 10 LinkedIn rows billed under the preset were on-site jobs in Midland TX, El Paso TX and Pittsburgh PA.

#### Why didn't the remote boards run on my location search?

By design: the seven remote-only boards carry remote jobs only, so they add nothing to a query like *nurse in Dallas* and are not run for it. They join when you name them in `sites`, or automatically when **Remote jobs only** is on and the board list is at its default. `RUN_SUMMARY.remote_boards_auto_added` tells you when the auto-add happened.

When they do run — a remote-only python developer search on 2026-08-08 at 10 requested per board — The Muse delivered 10, Jobicy 10, Hacker News 5, Himalayas 6 and Remotive 2 rows, so the auto-add earns its rows on a remote search and would add nothing but cost on a city one.

#### Does it include salary data?

Yes, where the board publishes it: a measured run filled `salary_min_annual` on 74 of 89 rows (83%), and hour

# Actor input Schema

## `preset` (type: `string`):

Pick a goal and the matching options below are switched on for you. A preset only fills options you left at their default — anything you set yourself always wins — and the run log plus <code>RUN\_SUMMARY.preset\_applied</code> list exactly what it changed.<br><br><b>Salary research</b> — keeps only rows that carry a salary, rewrites hourly and monthly pay into an annual figure, and sorts highest-paid first.<br><b>Competitor hiring intel</b> — turns on the company-details block; add the employers you are watching under <b>Only these companies</b> or the run stays unfiltered (the log warns you).<br><b>Remote-only sweep</b> — asks every board for remote roles only, which also brings in the seven remote-only boards when you left the board list untouched. This is the one preset that can <b>raise</b> your bill, because those extra boards add rows.<br><b>Fresh postings only</b> — drops anything posted more than 7 days ago after scraping (dropped rows are never billed), and asks the boards for the same window too when that cannot clash with your job-type / easy-apply / remote choices.<br><b>Recruiter lead-gen</b> — company block on every row and staffing-agency reposts dropped.

## `roleSelect` (type: `string`):

Pick a common job title instead of typing one, e.g. <code>Nurse</code>. Leave it on <b>Custom</b> to use the <b>Search term</b> box below — and note the reverse: picking a role here <b>overrides</b> that box, and only <b>Search terms (multiple)</b> outranks it. This is not a fixed taxonomy: the choice is sent to the boards as plain keywords, exactly as if you had typed them.

## `citySelect` (type: `string`):

Pick a metro instead of typing one, e.g. <code>Austin, TX</code>. Leave it on <b>Custom</b> to use the <b>Location</b> box below; picking a city here overrides that box, and only <b>Locations (multiple)</b> outranks it. Picking a non-US metro also switches <b>Indeed/Glassdoor country</b> to the matching country unless you set that field yourself — the run log and <code>RUN\_SUMMARY</code> say so when it happens. <b>Remote</b> simply searches the word <code>Remote</code> as the location; turn on <b>Remote jobs only</b> below if you also want the strict filter and the remote-only boards.

## `searchTerm` (type: `string`):

Enter the job title or keywords sent to every selected board, e.g. <code>registered nurse</code>. Ignored when you pick a <b>Role (quick pick)</b> above or fill <b>Search terms (multiple)</b> below; boards treat this as free text, so quotes and boolean operators are not guaranteed to work.

## `searchTerms` (type: `array`):

Run up to 5 searches in one go, merged and de-duplicated so a job posting found by two terms is billed once. Overrides both <b>Search term</b> and <b>Role (quick pick)</b>; terms beyond the 5th are reported in the run status and in <code>RUN\_SUMMARY.ignored\_search\_terms</code>, never dropped silently.

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

Enter the city, state or country to search in, e.g. <code>New York, NY</code>. Ignored when you pick a <b>City (quick pick)</b> above or fill <b>Locations (multiple)</b> below; leave it empty to search anywhere, and remember that one country-wide value returns a single capped batch of job postings per board while three cities return three.

## `locations` (type: `array`):

Search several places in one run, e.g. <code>Austin, TX</code> then <code>Denver, CO</code> — this is the way past a board's per-query cap. Replaces <b>Location</b> and <b>City (quick pick)</b>; capped at 10 locations and 50 term × location searches, always reported in <code>RUN\_SUMMARY</code>. <b>Cost warning:</b> every term × location pair asks each board for up to <b>Max job postings per board</b>, so 3 terms × 3 locations × 3 boards can bill up to 9× a single search — cross-search duplicates are still billed only once.

## `sites` (type: `array`):

Choose which boards to scrape. LinkedIn, Indeed, Glassdoor and The Muse are the default (The Muse joined 2026-08-14 after measuring 12/12 success from the standard proxy pool; it carries no salary data). Seven more are remote-only public APIs (Remotive, Jobicy, Himalayas, Hacker News <i>Who is hiring?</i>, Remote OK, We Work Remotely, Working Nomads): they are left out of the default on purpose because they add nothing to a location search, and they auto-join only when <b>Remote jobs only</b> is on and this list is still exactly the default boards. DevITjobs US/UK are opt-in tech boards with salary on ~100% of rows (annual, currency as published). Glassdoor tops out near 28-30 postings per query - a board-side cap, not a bug - and intermittently 403s entire runs; when it does, the run status names it and the missing board bills nothing. The five boards marked <b>blocked at source</b> return nothing right now and cost nothing to leave selected.

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

Paste job-search URLs straight from your browser and skip the form, e.g. <code>https://www.indeed.com/jobs?q=python+developer\&l=Austin%2C+TX</code>. Each URL scrapes only the board it came from and, when set, this replaces <b>Search term</b>, <b>Locations</b> and <b>Job boards</b> above. Supported: LinkedIn (<code>linkedin.com/jobs/search</code>), Indeed (any country sub-domain) and Glassdoor (both URL forms); each URL's own filters — keywords, location, posted-within, remote, job type, experience level, sort, paging — are honored on that URL's job postings only, before de-duplication and before billing (the README lists the exact honored parameters per board). One caveat, measured: a LinkedIn URL's <code>f\_WT</code> / <code>f\_JT</code> are forwarded but LinkedIn ignores them, and a URL alone does not switch on the post-scrape remote / job-type gate — set <b>Remote jobs only</b> / <b>Job type</b> in the form too and those rows are dropped before billing. A URL we cannot parse is skipped and reported in <code>RUN\_SUMMARY.search\_url\_errors</code> while the other URLs still run.

## `atsCompanies` (type: `array`):

Company names — the actor reads each company's own careers board directly (Greenhouse, Lever, Ashby, Recruitee, BambooHR or Workable — all keyless, tried in that order) and merges the live openings into the same table as the job boards. Fresher than any aggregator, straight from the source. <b>Leave the search term untouched and you get each company's whole board</b>; type your own term and only matching roles are delivered (and billed). A company not on any of the six ATSes is named in the log and costs nothing. BambooHR dates/descriptions and Workable descriptions come from one extra request per posting (newest first, at most 40 or 3× <b>Max job postings per board</b>, whichever is smaller) while <b>Full LinkedIn job details</b> is on. Up to 20 companies.

## `maxResults` (type: `integer`):

Set how many job postings to request from each board, e.g. <code>20</code> (1–500, values outside are clamped). You are billed per delivered row <i>after</i> de-duplication, so 3 boards × 30 bills up to 90 job postings minus duplicates; Glassdoor tops out near 28–30 per query whatever you set here.

## `isRemote` (type: `boolean`):

Ask Indeed and Glassdoor for <b>their own</b> remote filter, and gate every board's rows on their own <code>is\_remote</code> column after scraping (LinkedIn's <code>f\_WT</code> is forwarded but ignored at the source — measured); off means on-site and hybrid job postings come back too. When this is ON and you left <b>Job boards</b> at its default, the seven remote-only boards (Remotive, Jobicy, Himalayas, HN Who-is-Hiring, Remote OK, We Work Remotely, Working Nomads) join the run automatically, which means <b>more job postings and a bigger bill</b>. Selecting exactly the same default boards does not opt out — the run cannot tell that apart from the default — so to keep a fixed set, pick any selection other than the default set (add or drop one). Indeed and Glassdoor honor their own remote filter; LinkedIn's guest search accepts <code>f\_WT</code> and ignores it, returning the same on-site roles either way. So the Actor also drops rows whose own <code>is\_remote</code> column says <code>false</code> after scraping, <b>before</b> billing — those rows are free. A job posting with no description carries no evidence either way and is kept, so read <code>is\_remote</code> as "nothing here says otherwise" rather than a guarantee.

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

For scheduled runs. Remembers every job this search has already delivered to you (in a private key-value store in <i>your</i> account) and delivers — and bills — only postings it has not seen before. Measured without it: two identical runs a day apart shared 47% of their rows, so a schedule was re-buying half its data every run. The first run with this on delivers everything and starts the memory; memory is kept per search (terms + locations + boards) for 90 days. One honest limitation: the memory keys on each posting's own URL / listing id, so a posting the board re-lists under a new URL, or the same job arriving via a different board, can appear — and bill — again (measured: ~2.0% of suppressed rows).

## `jobType` (type: `string`):

Keep only one employment type, e.g. <b>Full-time</b>. Leave at <b>Any</b> to include every type.<br><br>Only Indeed and Glassdoor really filter this board-side: LinkedIn's guest search accepts the parameter and ignores it, and the five API boards have no employment-type filter at all. So the Actor also checks each job posting's own <code>job\_type</code> after scraping and drops the ones that contradict your choice — <b>before</b> billing, so those rows are free. A job posting the board published with no type at all is kept, because nothing about it says it is the wrong type. Note that Indeed cannot combine this with <b>Posted within (hours)</b> and drops the job type for that board only, which the run log reports; the after-scraping check still runs.

## `experienceLevel` (type: `array`):

Keep only job postings at these seniority levels, e.g. <b>Entry level</b>. Best-effort: when a board publishes the level we use it (100% of LinkedIn rows with <b>Full LinkedIn job details</b> on, plus Jobicy, Himalayas and The Muse), otherwise we read it off the job title (<code>Senior</code>/<code>Staff</code>/<code>Lead</code> → mid-senior, <code>Junior</code>/<code>Graduate</code> → entry, <code>Intern</code> → internship, <code>Director</code> → director, <code>VP</code>/<code>Chief</code> → executive). A job posting whose level we cannot determine is <b>kept</b>, never silently dropped, and filtered rows are never charged; leave empty to keep every level.

## `requireSalary` (type: `boolean`):

Keep only job postings that carry a salary figure (<code>salary\_min</code> or <code>salary\_max</code>). Applied after de-duplication and before billing, so rows without a salary are never charged; Glassdoor and Indeed publish salary most often, LinkedIn rarely.

## `hoursOld` (type: `integer`):

Ask the boards for job postings published in the last N hours, e.g. <code>168</code> for the last week (<code>24</code> = last day). This is a board-side filter, so it cannot be combined with <b>Easy Apply only</b> on LinkedIn/Indeed or with job type / remote on Indeed — the clashing option is dropped for that board and the log says so. Leave empty for all ages, or use <b>Max posting age (days)</b> instead, which never clashes. Anything above 8760 (one year) behaves the same as leaving it empty — the age limit is dropped and your other board-side filters are all kept.

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

Drop job postings older than N days, e.g. <code>7</code> for the last week. Applied by the Actor after scraping and before billing, so filtered rows are never charged; unlike <b>Posted within (hours)</b> it works on every board and never clashes with another filter. Job postings the board published without a date are kept; empty keeps everything. Any number you type is applied literally — <code>400</code> means 400 days.

## `targetCompanies` (type: `array`):

Keep <b>only</b> job postings from these employers, e.g. <code>Deloitte</code> — everything else is dropped, which is how you watch who a competitor is hiring. Names are normalized the way de-duplication does them (<code>Google</code> also matches <code>Google LLC</code>, and <code>Amazon</code> keeps <code>Amazon Web Services</code> but not <code>Amazonia</code>); applied after de-duplication and before billing, so dropped rows are never charged. Leave empty to keep every company.

## `excludeCompanies` (type: `array`):

Drop these employers, e.g. a staffing agency or your own company. Case-insensitive and legal suffixes are ignored, so <code>Wipro</code> also excludes <code>Wipro Limited</code> and <code>Wipro Technologies</code> (same rule as <b>Only these employers</b>, and <code>Wiprox</code> is left alone); filtered job postings are never charged.

## `excludeTitleKeywords` (type: `array`):

Drop job postings whose title contains any of these words or phrases, e.g. <code>senior</code>, <code>intern</code>, <code>clearance</code>. Case-insensitive substring match against the title only (not the description), and filtered job postings are never charged.

## `requireTitleKeywords` (type: `array`):

Keep only job postings whose title contains <b>at least one</b> of these words or phrases, e.g. <code>intern</code>, <code>internship</code>. Case-insensitive substring match against the title only (not the description) — the mirror of <b>Exclude title keywords</b>. Useful when a board matches your search term against descriptions and returns non-matching roles. Applied after scraping and before billing, so filtered job postings are never charged; <code>RUN\_SUMMARY.filter\_removed.requireTitleKeywords</code> counts them. Does not touch the <b>Only new jobs</b> memory: rows it drops are simply never delivered, so adding or removing it on a schedule never re-delivers old rows.

## `excludeCities` (type: `array`):

Drop job postings whose location mentions any of these places, e.g. <code>San Francisco</code> or <code>Remote</code>. Whole-word, case-insensitive match against the location text exactly as the board published it — <code>Dallas</code> drops <code>Dallas, TX</code> while <code>Dall</code> drops nothing. Best-effort, because boards format locations inconsistently; filtered job postings are never charged.

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

Drop job postings whose <b>company name</b> looks like a staffing agency, recruiter or body shop (staffing, recruit\*, headhunt\*, talent\*, manpower, placement\*, consultanc\*, workforce, <code>HR solutions</code>…). Heuristic by design — it reads the company name only, so a neutrally-named re-poster slips through and a genuine employer called <code>Workforce</code> is dropped; use <b>Exclude companies</b> for exact control. Filtered job postings are never charged.

## `strictKeywordMatch` (type: `boolean`):

Drop job postings whose title and description do not contain your search words. Off by default, because "loosely related" is what some searches want.<br><br>Turn it on for a narrow or unusual role. LinkedIn's guest search never answers "nothing matched" — it falls back to whatever it considers close, so a search for <code>underwater welder</code> in Fargo comes back full of plumbers and yard attendants, and you pay for them. This drops those rows before billing, so it can only ever <b>lower</b> your bill. Either way the run counts how many delivered job postings never mention your search term and reports it per board in <code>RUN\_SUMMARY.rows\_not\_mentioning\_search\_term</code>, so you can check before deciding.

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

Choose the format of the <code>description</code> column. Markdown is the best input for LLMs and spreadsheets; HTML keeps the board's original markup.

## `descriptionHtml` (type: `boolean`):

Add a <code>description\_html</code> column holding the description exactly as the board published it, next to the normal <code>description</code> in your chosen format — same run, no extra requests, no extra cost per job posting. Use the HTML for original formatting (links, lists, headings) and the Markdown for LLMs and spreadsheets; off by default because HTML roughly doubles dataset size.

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

Kept for compatibility — since v0.1.48 the whole company block ships on <b>every</b> run, so this switch changes nothing (the run log says so when you set it). <code>company\_ceo</code>, <code>company\_banner</code>, <code>company\_addresses\_all</code> and the <code>company\_details\_filled</code> counter fill automatically on the rows Indeed published them for; everything else in the block (<code>company\_url</code>, industry, description, size band, revenue, addresses, logo, rating) was already unconditional. Nothing is invented — what a board does not publish stays null, and every row of every run carries all 50 columns. Indeed supplies the richest block; LinkedIn adds industry and <code>company\_url</code> when <b>Full LinkedIn job details</b> is on; Glassdoor adds rating and logo (the only board publishing <code>company\_rating</code> — measured across every row ever delivered: 88.5% of Glassdoor rows, 10.1% of all rows); the remaining boards supply little or nothing — measured fill rates are in the README.

## `resumeKeywords` (type: `array`):

Score every job posting against your skills or phrases, e.g. <code>Python</code>, <code>Kubernetes</code>, <code>SOC 2</code>, <code>C++</code> — <code>matched\_keywords</code> and <code>keyword\_match\_percent</code> (0–100) then score against this list. Leave it empty and the same two columns score every row against <b>your search terms</b> instead (so they are filled either way); filling it replaces the scoring set rather than switching the columns on. This <b>never</b> removes a job posting and never changes billing, it only annotates so you can sort or triage by fit; case-insensitive substring match, so <code>c++</code>, <code>.NET</code> and <code>node.js</code> work as written. Pairs best with <b>Sort order</b> = Relevance.

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

Choose the order of the final, de-duplicated job postings, e.g. <b>Newest first</b>. Applied before the charge cap, so a truncated run keeps the job postings you ranked highest. <b>Relevance</b> means best keyword match first (your <b>Resume keywords</b> when given, otherwise your search terms), then job postings found on the most boards, then newest; rows missing the chosen key (no date, no salary) always sort last, and <b>Board order</b> keeps the original behaviour.

## `enforceAnnualSalary` (type: `boolean`):

Convert hourly and monthly salaries into an annual figure in <code>salary\_min</code>/<code>salary\_max</code> where the board gave enough to do it. You rarely need this: <code>salary\_min\_annual</code> and <code>salary\_max\_annual</code> are computed on every row regardless, and this switch is what rewrites the raw columns too.

## `countryIndeed` (type: `string`):

Choose which country's Indeed and Glassdoor site to query, e.g. <code>usa</code>, <code>uk</code>, <code>canada</code>, <code>india</code>, <code>germany</code>. Only these two boards use it — LinkedIn and the API boards read the country out of your location text.<br><br>You can usually leave this alone: if you type a location that names a country (<code>Berlin, Germany</code>, <code>Casablanca, Morocco</code>) the Actor points Indeed at that country's site for you, exactly as picking a non-US metro under <b>City (quick pick)</b> does — anything you type here always wins. A spelling this Actor does not recognise is reported in the log and falls back to <code>usa</code> instead of failing the run.

## `linkedinFetchDescription` (type: `boolean`):

Open each LinkedIn job posting's own page — on by default, because it lifts description 64%→100%, job\_type 24%→65% and company\_industry 2%→38% (measured on 72 rows) at the same price per row. With this OFF, LinkedIn rows carry no <code>description</code>, <code>job\_type</code>, <code>job\_level</code>, <code>job\_function</code> or <code>company\_industry</code> at all — but the run is faster by one request per LinkedIn job posting.

## `linkedinCompanyIds` (type: `array`):

Restrict LinkedIn job postings to these numeric company IDs, e.g. <code>1441</code> — take them from the <code>f\_C</code> parameter of a LinkedIn search URL. LinkedIn only; every other board ignores it.

## `distance` (type: `integer`):

Set the search radius around the location in miles, e.g. <code>25</code>. LinkedIn and Indeed honor it; Glassdoor and the API boards ignore it, and it does nothing at all without a location.

## `easyApply` (type: `boolean`):

Keep only direct/easy-apply job postings on LinkedIn and Indeed; the other boards ignore it. Cannot be combined with <b>Posted within (hours)</b> on those two boards — when both are set, this one is dropped for them and the log says so.

## `offset` (type: `integer`):

Skip the first N job postings on each board, e.g. <code>50</code> to page past a run you already have. Combine with <b>Max job postings per board</b> to walk a large search in chunks. LinkedIn, Indeed and Glassdoor page server-side; the five API boards have no paging of their own, so the Actor fetches N extra job postings there and hands you the tail. Either way boards apply it to their own ordering, so it is not a stable cursor — a posting added between two runs can shift the window.

## `googleSearchTerm` (type: `string`):

Enter a full Google Jobs search string, e.g. <code>software engineer jobs near New York since yesterday</code>. Leave empty and one is built from your search term plus location — and note Google Jobs is blocked at the source right now, so this changes nothing today.

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

Datacenter proxy (the default) is what you want: measured 2026-08-11 on the same search, it returned 52 rows in 50s for $0.003, against 54 rows in 67s for $0.015 on residential — 96% of the rows at a quarter of the price. Residential is billed per gigabyte; datacenter is not. Switch to RESIDENTIAL only if Glassdoor starts coming back short for your search. Running with no proxy at all is the worst of the three: Indeed and Glassdoor time out, so you get LinkedIn only (20 rows) and still pay more, because the failing requests burn compute.

## `userAgent` (type: `string`):

Send a custom <code>User-Agent</code> header instead of the built-in one, e.g. a current desktop Chrome string. Leave empty unless a board is blocking the default.

## `caCert` (type: `string`):

Point to a custom CA certificate, by path or by contents — only needed when your own proxy terminates TLS with a private root. Leave empty for Apify Proxy.

## `webhookUrl` (type: `string`):

Optional. When at least one row is delivered, the run POSTs a digest to this URL: a Slack incoming webhook gets a text message, a Discord webhook gets a message, any other URL (n8n / Make / Zapier catch hook, your own endpoint) gets JSON with the counts, console links and the first 20 rows. Quiet runs send nothing. Pair it with onlyNewJobs on a schedule and this actor becomes an alert service on its own. Delivery is best-effort: a webhook failure is reported in the status message and never fails the run.

## Actor input object example

```json
{
  "preset": "salary_research",
  "roleSelect": "registered nurse",
  "citySelect": "Austin, TX",
  "searchTerm": "software engineer",
  "searchTerms": [
    "data analyst",
    "business analyst"
  ],
  "location": "New York, NY",
  "locations": [
    "Austin, TX",
    "Chicago, IL",
    "Denver, CO"
  ],
  "sites": [
    "linkedin",
    "indeed",
    "glassdoor",
    "muse"
  ],
  "searchUrls": [
    "https://www.indeed.com/jobs?q=python+developer&l=Austin%2C+TX&fromage=14"
  ],
  "atsCompanies": [
    "Stripe",
    "OpenAI",
    "Spotify"
  ],
  "maxResults": 20,
  "isRemote": false,
  "onlyNewJobs": false,
  "jobType": "fulltime",
  "experienceLevel": [
    "entry",
    "associate"
  ],
  "requireSalary": false,
  "hoursOld": 168,
  "maxAgeDays": 7,
  "targetCompanies": [
    "Deloitte",
    "Accenture"
  ],
  "excludeCompanies": [
    "Robert Half"
  ],
  "excludeTitleKeywords": [
    "senior",
    "intern"
  ],
  "requireTitleKeywords": [
    "intern",
    "internship"
  ],
  "excludeCities": [
    "San Francisco"
  ],
  "excludeAgencies": false,
  "strictKeywordMatch": false,
  "descriptionFormat": "markdown",
  "descriptionHtml": false,
  "includeCompanyDetails": false,
  "resumeKeywords": [
    "Python",
    "AWS",
    "Kubernetes"
  ],
  "sortBy": "date_desc",
  "enforceAnnualSalary": false,
  "countryIndeed": "usa",
  "linkedinFetchDescription": true,
  "linkedinCompanyIds": [
    "1441"
  ],
  "distance": 25,
  "easyApply": false,
  "offset": 50,
  "googleSearchTerm": "software engineer jobs near New York since yesterday",
  "proxyConfiguration": {
    "useApifyProxy": true
  }
}
```

# Actor output Schema

## `results` (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 = {
    "searchTerm": "software engineer",
    "location": "New York, NY",
    "atsCompanies": [],
    "proxyConfiguration": {
        "useApifyProxy": true
    }
};

// Run the Actor and wait for it to finish
const run = await client.actor("flash_scraper/multi-jobboard-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 = {
    "searchTerm": "software engineer",
    "location": "New York, NY",
    "atsCompanies": [],
    "proxyConfiguration": { "useApifyProxy": True },
}

# Run the Actor and wait for it to finish
run = client.actor("flash_scraper/multi-jobboard-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 '{
  "searchTerm": "software engineer",
  "location": "New York, NY",
  "atsCompanies": [],
  "proxyConfiguration": {
    "useApifyProxy": true
  }
}' |
apify call flash_scraper/multi-jobboard-scraper --silent --output-dataset

```

## MCP server setup

```json
{
    "mcpServers": {
        "apify": {
            "type": "http",
            "url": "https://mcp.apify.com/?tools=fetch-actor-details,flash_scraper/multi-jobboard-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/HzvpqlBISk1PaUKJ3/builds/ihvNg3CmgUqUKPCBt/openapi.json
