# Multi Board Job Scraper — Indeed, LinkedIn & Employer Boards (`brilliant_gum/jobs-aggregator`) Actor

Search Indeed, LinkedIn and 56 employer career boards (Greenhouse, Lever, Ashby) in one run. Every posting is mapped onto one schema, deduplicated across boards and runs, with pay parsed into min/max/currency/period and seniority inferred from the title. No login, no browser.

- **URL**: https://apify.com/brilliant\_gum/jobs-aggregator.md
- **Developed by:** [Yuliia Kulakova](https://apify.com/brilliant_gum) (community)
- **Categories:** Jobs, Lead generation, Automation
- **Stats:** 3 total users, 2 monthly users, 100.0% runs succeeded, 0 bookmarks
- **User rating**: No ratings yet

## Pricing

from $0.008 / job posting

This Actor is paid per event and usage. You are charged both the fixed price for specific events and for Apify platform usage.

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 Board Job Scraper — Indeed, LinkedIn & Employer Boards

![Multi Board Job Scraper](https://api.apify.com/v2/key-value-stores/IuFu0Cw6PpOkNkDV7/records/jobs-aggregator.png)

**One job search across several boards at once, mapped onto a single schema and deduplicated — including the employer career boards most job scrapers skip.** No login, no browser.

***

### What it does

Most job scrapers cover one board. This one runs your search across several at once, maps every posting — whichever board it came from — onto **one flat, consistent record**, and drops duplicates both **within a run** (the same job cross-posted to two boards) and **across scheduled runs**, so a daily run can emit only what is new.

Everything is fetched over plain HTTP — no headless browser — so runs are fast and cheap.

Built for **recruiters and sourcers**, **job-market and pay analytics**, and anyone **building or backfilling a jobs board**.

***

### Which boards you actually get

Verified on 2026-09-10. This table is the honest version — please read it before you buy.

| Board | Status |
|---|---|
| **Indeed** | ✅ Full search: query, location, country, radius, date filter |
| **LinkedIn** | ✅ Full search, optional full description + board criteria per posting |
| **Greenhouse** | ✅ Employer career boards — 32 curated companies, or pass your own |
| **Lever** | ✅ Employer career boards — 6 curated companies, or pass your own |
| **Ashby** | ✅ Employer career boards — 18 curated companies, with structured pay bands |
| **Glassdoor** | ⚠️ Works **only without `location`** — Glassdoor blocks its own location lookup (HTTP 403), so the board is skipped, with the reason in the log, whenever a location is set. Descriptions are short search-page snippets unless you turn on `glassdoorFullDescription`. |
| **ZipRecruiter** | ⚠️ **United States and Canada only.** Any other country is refused with a reason rather than answered with the wrong place. |
| **Google Jobs** | ❌ **Does not currently work.** Google guards its jobs widget; the board reports itself as blocked and is skipped. Never faked with placeholder data. |

**The employer boards are the point.** Greenhouse, Lever and Ashby are companies' own public career APIs — real employer-posted openings with clean structured fields and, on Ashby, real pay bands. Most multi-board scrapers don't touch them. The 56 curated boards were each checked to answer with live postings; you can also pass your own board tokens to target specific employers.

***

### Key features

- **Pick any subset of boards** in one `boards[]` input, in one run.
- **One normalized record for every board.** `board` is kept on each row, so you always know where a posting came from.
- **Cross-board and cross-run dedup.** Each posting gets a stable `dedupKey` built from company + title + city, after normalizing the things boards write differently: `Acme Corp` / `Acme Inc` / `Acme`, `Sr.` / `Senior`, `New York` / `New York, NY, United States`. Point `deduplicateKeyStore` at a named key-value store to keep the keys between scheduled runs.
- **Salary you can actually filter on.** `salary { min, max, currency, period, source }` — the board's own figure when it publishes one, otherwise parsed from the posting text. In a description a number is only read as pay when a currency marker sits with it, so ordinary prose like "3-5 years of experience" or "a team of 50-200" never becomes a wage, and company money — "$70 billion in annual sales", "a $5.8B valuation" — is not mistaken for it. The period is read from right beside the amount, so an office-days sentence can't turn an annual figure into a weekly one. Set `enforceAnnualSalary` to convert everything to an annual equivalent.
- **Seniority and job type on every board.** `seniority` from the title, plus `seniorityAll` with every level a title carries — "Senior Engineering Manager" is genuinely both `senior` and `manager`, and a single field would have to drop one. `jobType` from structured signals or the text.
- **Company extras where the board publishes them** — rating, size, revenue, description, logo, industry. Filled only when the board actually returns them, never invented.
- **Proxy fallback.** Boards are fetched through the proxy group you configure; if that group keeps failing, the last retries switch to a datacenter session automatically and the switch is noted in the log. A group outage degrades a run instead of ending it.
- **`RUN_SUMMARY`** in the key-value store: per-board counts, salary coverage, remote share, seniority and job-type breakdown, top companies, and every board that was skipped with the reason.

***

### Input

Everything is optional except a `query` (or `queries`).

| Field | Type | Default | Description |
|---|---|---|---|
| `query` | string | `software engineer` | Job title, keyword or skill, searched on every selected board. |
| `queries` | array\<string> | – | Several searches in one run, merged and deduped; each row is tagged `matchedQuery`. Max 5. |
| `location` | string | `remote` | City, state, country or `remote`. Applies to Indeed and LinkedIn. **Glassdoor cannot be filtered by location** and is skipped when one is set. ATS boards are filtered in-process. |
| `boards` | array\<enum> | `["indeed","linkedin","glassdoor","greenhouse"]` | `indeed`, `linkedin`, `greenhouse`, `lever`, `ashby`, `glassdoor`, `ziprecruiter`, `google` — see the table above for what each one can do. |
| `maxItemsPerBoard` | integer | `25` | Postings per board, per query (`0` = board maximum). |
| `remoteOnly` | boolean | `false` | Keep only remote postings. |
| `jobType` | enum | any | `fulltime` / `parttime` / `contract` / `internship` / `temporary`. |
| `maxDaysOld` | integer | `0` | Only postings from the last N days (`0` = any). |
| `country` | string | `us` | Country for Indeed and Glassdoor (`us`, `uk`, `canada`, `germany`, `india`, …). ZipRecruiter accepts only `us` / `canada`. |
| `distance` | integer | `50` | Search radius in miles for Indeed and LinkedIn (0–200). |
| `fetchDescription` | boolean | `true` | Fetch the full LinkedIn description and board criteria per posting (one extra request each). |
| `glassdoorFullDescription` | boolean | `false` | Fetch each Glassdoor posting's full description instead of the search-page snippet (one extra request each). |
| `descriptionFormat` | enum | `text` | `text` / `markdown` / `html`. |
| `greenhouseCompanies` | array\<string> | curated 32 | Greenhouse board tokens — the slug in the careers URL, e.g. `stripe`. A company not hosted on Greenhouse simply returns nothing. |
| `leverCompanies` | array\<string> | curated 6 | Lever board tokens, e.g. `palantir`. |
| `ashbyCompanies` | array\<string> | curated 18 | Ashby board tokens, e.g. `ramp`. |
| `parseSalary` | boolean | `true` | Parse pay from text when the board publishes no structured figure. |
| `enforceAnnualSalary` | boolean | `false` | Convert every salary to its annual equivalent. |
| `computeAnalytics` | boolean | `true` | Write the `RUN_SUMMARY` roll-up. |
| `deduplicate` | boolean | `true` | Drop duplicates by `dedupKey`. |
| `deduplicateKeyStore` | string | – | Named key-value store that keeps seen keys between runs. |
| `monitoringMode` | boolean | `false` | Emit only postings not seen in a previous run (needs `deduplicateKeyStore`). |
| `maxConcurrency` | integer | `5` | Concurrency for detail fetches and employer boards (1–20). |
| `maxChallengeRetries` | integer | `3` | Retries with a fresh proxy session on 403/429/5xx/timeout. |
| `proxy` | object | Residential US | Proxy for Indeed and LinkedIn. If this group keeps failing, later retries fall back to a datacenter session automatically. Glassdoor and ZipRecruiter need the Apify Unblocker group and use it internally. Employer boards need no proxy. |

#### Example input

```json
{
  "query": "backend engineer",
  "location": "remote",
  "boards": ["indeed", "linkedin", "greenhouse", "ashby"],
  "maxItemsPerBoard": 25,
  "enforceAnnualSalary": true,
  "deduplicate": true
}
```

***

### Output

One flat record per posting. Below is a **real record** from a run against the `ashby` board, trimmed only by shortening the description.

```json
{
  "board": "ashby",
  "jobId": "ash-ramp-7bfa613e-151c-469b-9973-c89ee3d14838",
  "title": "Backend Engineer, Ops Engineering",
  "company": "Ramp",
  "location": { "text": "New York, NY (HQ)", "city": "New York City", "state": "NY", "country": "US" },
  "remote": true,
  "salary": { "source": "structured", "min": 168000, "max": 284900, "currency": "USD", "period": "yearly" },
  "description": "About Ramp — Ramp is building the smart infrastructure for finance teams …",
  "url": "https://jobs.ashbyhq.com/ramp/7bfa613e-151c-469b-9973-c89ee3d14838",
  "applyUrl": "https://jobs.ashbyhq.com/ramp/7bfa613e-151c-469b-9973-c89ee3d14838/application",
  "postedDate": "2026-03-04",
  "seniority": "mid",
  "seniorityAll": ["mid"],
  "jobType": "fulltime",
  "department": "Engineering",
  "emails": null,
  "dedupKey": "e1b782aa46f97b7d08c71542c7139f3580447532",
  "matchedQuery": "backend engineer",
  "scrapedAt": "2026-09-10T06:31:44.327Z"
}
```

**Field reference** — `board`, `jobId`, `title`, `company`, `location{text,city,state,country}`, `remote`, `salary{min,max,currency,period,source}`, `description`, `url`, `applyUrl`, `postedDate`, `seniority`, `seniorityAll[]`, `jobType`, `department`, `industry`, `emails[]`, `dedupKey`, `companyLogo`, `companyUrl`, `companyRating`, `companyReviewsCount`, `companyNumEmployees`, `companyRevenue`, `companyDescription`, `companyAddresses`, `jobFunction`, `jobLevel`, `listingType`, `matchedQuery`, `scrapedAt`.

Country codes are normalized to ISO-2, so `GB` from one board and `United Kingdom` from another end up the same.

***

### Pricing

You pay for postings, not for runtime. The full price list is two entries:

| Event | When it is charged |
|---|---|
| **`job-listing`** | One posting written to your dataset. |
| `apify-actor-start` | Once when a run starts (Apify charges one per GB of memory). |

Duplicates dropped by the deduplicator, postings removed by your filters, and boards that were skipped are **not charged**.

Cap a run's cost with `maxItemsPerBoard`.

***

### Use cases

- **Sourcing** — one search across several boards, deduplicated, in a single dataset.
- **Pay benchmarking** — `salary` with `enforceAnnualSalary` gives comparable annual figures across boards and currencies.
- **Job-market tracking** — schedule a daily run with `deduplicateKeyStore` and `monitoringMode` to receive only new postings.
- **Filling a jobs board** — employer ATS boards give clean, structured, employer-posted openings.

***

### Limitations

- **Glassdoor cannot be filtered by location.** Glassdoor returns HTTP 403 for its own location lookup, so a location-specific Glassdoor search is not possible. Run without `location` to include Glassdoor, or use the other boards for location searches. The board is skipped with the reason in the log — never quietly answered with the wrong place.
- **Glassdoor descriptions are snippets by default** (typically ~150 characters). `glassdoorFullDescription` fetches the full text at one extra request per posting.
- **ZipRecruiter covers the United States and Canada only.**
- **Google Jobs does not currently work.** It is left in the board list because the code is there and Google's guard may lift, but expect it to be skipped.
- **Employer boards depend on which companies are in scope.** The curated sets cover 56 boards; a company not hosted on that platform returns nothing. Pass your own tokens to target specific employers.
- **A skipped board never fails the run.** It is recorded in `RUN_SUMMARY.blocked` and the run completes with what the other boards returned. The run fails loudly only when every board on every query was blocked, so an outage is never reported as an empty result.
- **Salary is `null` when nothing trustworthy was found.** The parser would rather return nothing than turn a company's revenue into a wage.

***

### FAQ

**Do I need a login or cookies?** No. Every board is read from its public, no-auth endpoint.

**Which boards need a proxy?** Indeed and LinkedIn use your `proxy` setting (residential recommended). Glassdoor and ZipRecruiter need the Apify Unblocker group. Employer boards need no proxy at all.

**How does deduplication work?** Each posting gets a `dedupKey` from company + title + city, normalized so the same job posted to two boards produces one key. Within a run duplicates are dropped; set `deduplicateKeyStore` to carry keys across scheduled runs and add `monitoringMode` to emit only new postings.

**Can I search several roles at once?** Yes — `queries` takes up to 5, results are merged and deduped, and each row carries the `matchedQuery` that found it.

**How do I target specific companies?** Pass their board tokens in `greenhouseCompanies`, `leverCompanies` or `ashbyCompanies` — the slug from the careers URL.

**Why is `salary` sometimes empty?** The posting published no pay and none could be read from its text with confidence.

***

### Legal

Public data only — no login, no cookie wall bypassed, no personal accounts touched. Greenhouse, Lever and Ashby boards are the employers' own public career APIs. You remain responsible for complying with each board's terms and with applicable law; any personal data is limited to what employers publish in their own postings.

# Actor input Schema

## `query` (type: `string`):

Job title, keyword or skill to search across every selected board (e.g. "software engineer", "data analyst"). Required unless you use "Multiple queries" below.

## `queries` (type: `array`):

Optional. Run several queries in one go — results are merged and deduplicated, each row tagged with matchedQuery. Max 5. Leave empty to use the single query above.

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

City / state / country, or "remote". Applies to Indeed, LinkedIn and Glassdoor; ATS boards are filtered client-side. Empty = anywhere. Note: Glassdoor cannot be filtered by location and is skipped (with a reason in the log) when one is set.

## `boards` (type: `array`):

Which job boards to search. Verified working: indeed, linkedin, greenhouse, ashby, lever. glassdoor works only WITHOUT a `location` (Glassdoor blocks its location lookup). ziprecruiter covers the US and Canada only. google is experimental — Google guards its jobs widget and it usually returns nothing.

## `maxItemsPerBoard` (type: `integer`):

Maximum postings to collect per board (per query). Total ≈ this × boards × queries, before dedup. 0 = board maximum.

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

Keep only remote postings (filters both the board query and the normalized results).

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

Filter by employment type. "Any" returns all types.

## `maxDaysOld` (type: `integer`):

Only keep jobs posted within the last N days (converted to hours for Indeed/LinkedIn, to a fromAge filter for Glassdoor). 0 = any age.

## `country` (type: `string`):

Country for Indeed and Glassdoor (us, uk, canada, australia, germany, france, india, netherlands, singapore, ...). Defaults to us.

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

Radius in miles from the location for Indeed and LinkedIn.

## `fetchDescription` (type: `boolean`):

Fetch the full job description + seniority/employment-type/industry from each LinkedIn job page (one extra request per posting; slower + more proxy usage). Indeed/Glassdoor/ATS descriptions come with the search response.

## `glassdoorFullDescription` (type: `boolean`):

By default Glassdoor postings carry the short description snippet from the search page (no extra request). Enable this to fetch each Glassdoor job's full description from its detail page (one extra UNBLOCKER request per posting — slower and more proxy usage).

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

Output format for the job description text.

## `greenhouseCompanies` (type: `array`):

Greenhouse board tokens to search (the slug in boards.greenhouse.io/<token>, e.g. "stripe"). Empty = a curated set of popular public boards. These are Greenhouse board tokens, i.e. the name in the careers URL — a company not hosted on Greenhouse simply returns nothing.

## `leverCompanies` (type: `array`):

Lever board tokens to search (the slug in jobs.lever.co/<token>, e.g. "palantir"). Empty = a curated set. These are Lever board tokens, i.e. the name in the careers URL — a company not hosted on Lever simply returns nothing.

## `ashbyCompanies` (type: `array`):

Ashby board tokens to search (the slug in jobs.ashbyhq.com/<token>, e.g. "ramp"). Empty = a curated set. These are Ashby board tokens, i.e. the name in the careers URL — a company not hosted on Ashby simply returns nothing.

## `parseSalary` (type: `boolean`):

When a posting has no structured salary, parse min/max/currency/period from the salary text or description (best-effort).

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

Convert every parsed/structured salary to an annual equivalent (hourly × 2080, monthly × 12, ...) for easy comparison.

## `computeAnalytics` (type: `boolean`):

Save a RUN\_SUMMARY record (per-board counts, salary coverage, remote share, seniority/job-type breakdown, top companies, blocked boards) to the key-value store.

## `deduplicate` (type: `boolean`):

Drop duplicate postings across boards using a company+title+location hash (dedupKey).

## `deduplicateKeyStore` (type: `string`):

Named key-value store to persist seen dedupKeys across scheduled runs. Empty = in-run dedup only.

## `monitoringMode` (type: `boolean`):

Only output postings not seen in a previous run (requires a cross-run dedup key store). Great for scheduled new-job alerts.

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

Concurrency for per-item detail fetches (LinkedIn descriptions) and ATS board fetches.

## `maxChallengeRetries` (type: `integer`):

Retries with fresh proxy sessions on 403/429/5xx/timeout responses.

## `proxy` (type: `object`):

Egress proxy for Indeed/LinkedIn/Google. Residential US is recommended (LinkedIn rate-limits per IP). Glassdoor always uses the Apify UNBLOCKER group internally regardless of this setting.

## Actor input object example

```json
{
  "query": "software engineer",
  "location": "remote",
  "boards": [
    "indeed",
    "glassdoor",
    "greenhouse"
  ],
  "maxItemsPerBoard": 10,
  "remoteOnly": false,
  "jobType": "",
  "maxDaysOld": 0,
  "country": "us",
  "distance": 50,
  "fetchDescription": true,
  "glassdoorFullDescription": false,
  "descriptionFormat": "text",
  "parseSalary": true,
  "enforceAnnualSalary": false,
  "computeAnalytics": true,
  "deduplicate": true,
  "monitoringMode": false,
  "maxConcurrency": 5,
  "maxChallengeRetries": 3,
  "proxy": {
    "useApifyProxy": true,
    "apifyProxyGroups": [
      "RESIDENTIAL"
    ],
    "apifyProxyCountry": "US"
  }
}
```

# Actor output Schema

## `jobs` (type: `string`):

No description

## `runSummary` (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 = {
    "query": "software engineer",
    "location": "remote",
    "boards": [
        "indeed",
        "glassdoor",
        "greenhouse"
    ],
    "maxItemsPerBoard": 10,
    "proxy": {
        "useApifyProxy": true,
        "apifyProxyGroups": [
            "RESIDENTIAL"
        ],
        "apifyProxyCountry": "US"
    }
};

// Run the Actor and wait for it to finish
const run = await client.actor("brilliant_gum/jobs-aggregator").call(input);

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

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

```

## Python example

```python
from apify_client import ApifyClient

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

# Prepare the Actor input
run_input = {
    "query": "software engineer",
    "location": "remote",
    "boards": [
        "indeed",
        "glassdoor",
        "greenhouse",
    ],
    "maxItemsPerBoard": 10,
    "proxy": {
        "useApifyProxy": True,
        "apifyProxyGroups": ["RESIDENTIAL"],
        "apifyProxyCountry": "US",
    },
}

# Run the Actor and wait for it to finish
run = client.actor("brilliant_gum/jobs-aggregator").call(run_input=run_input)

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

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

```

## CLI example

```bash
echo '{
  "query": "software engineer",
  "location": "remote",
  "boards": [
    "indeed",
    "glassdoor",
    "greenhouse"
  ],
  "maxItemsPerBoard": 10,
  "proxy": {
    "useApifyProxy": true,
    "apifyProxyGroups": [
      "RESIDENTIAL"
    ],
    "apifyProxyCountry": "US"
  }
}' |
apify call brilliant_gum/jobs-aggregator --silent --output-dataset

```

## MCP server setup

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

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

## OpenAPI specification

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