# LinkedIn Jobs Scraper (`spookyweb/linkedin-jobs`) Actor

Export LinkedIn jobs with company enrichment, CV keyword matching, applicant counts and annualised salary. No login, no cookie, no proxy needed.

- **URL**: https://apify.com/spookyweb/linkedin-jobs.md
- **Developed by:** [丂卩ㄖㄖҜㄚ](https://apify.com/spookyweb) (community)
- **Categories:** Jobs, Lead generation, AI
- **Stats:** 1 total users, 0 monthly users, 100.0% runs succeeded, 0 bookmarks
- **User rating**: No ratings yet

## Pricing

from $0.60 / 1,000 job delivereds

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/platform/actors/running/actors-in-store#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

## LinkedIn Jobs Scraper

Export LinkedIn job listings with company enrichment, CV keyword matching, applicant counts and salary parsing. No login, no session cookie, no proxy required.

### 🔍 What does LinkedIn Jobs Scraper do?

It runs a job search on LinkedIn and returns every listing it finds as a clean row of data, ready for a spreadsheet, a database or your own product.

**No login, no session cookie, no proxy required.** This reads LinkedIn's public guest endpoints, the ones that serve logged out job browsing, so there is no session to expire and no account of yours at risk.

On top of the listing itself you can add company details, filter on applicant counts and score every job against your own CV keywords.

### 📊 What data can I extract from LinkedIn?

One row per job:

| Field | Notes |
|---|---|
| `jobId`, `title`, `company`, `companyUrl`, `location` | The listing and who posted it |
| `postedAt`, `url` | Posting date and a direct link |
| `applicants`, `applicantsBasis`, `isEarlyApplicant` | How many have applied, and how firm that number is |
| `applyType`, `seniority`, `employmentType` | How to apply and at what level |
| `jobFunction`, `industries` | LinkedIn's own categorisation |
| `description`, `descriptionLength` | Full job description |
| `salaryFrom`, `salaryTo`, `salaryCurrency`, `salaryPeriod`, `salaryRaw`, `hasSalary` | Pay as published |
| `listingSalary` | Salary exactly as written on the listing, before any parsing |
| `salaryAnnualisedFrom`, `salaryAnnualisedTo` | Annual equivalent so hourly, daily and annual roles compare in one column |
| `payBand`, `payScheme` | NHS style pay bands, kept as facts rather than converted |
| `keywordMatchScore`, `matchedKeywords`, `unmatchedKeywords` | CV match, when `resumeKeywords` is set |
| `companyName`, `companyEmployees`, `companyFollowers`, `companyIndustry`, `companyWebsite`, `companyDescription`, `companyCity`, `companyCountry` | Added by `enrichCompanyData` |
| `searchKeywords`, `searchLocation`, `retrievedAt` | The search that produced the row, and when |

### 💡 Why scrape LinkedIn jobs?

**Jobseekers.** Filter to early applicant roles matching your CV, at direct employers rather than agencies.

**Recruiters.** Track competitor hiring by company, size and industry.

**Salary benchmarking.** Annualised pay by title and region, with NHS bands handled properly.

**Market research.** Hiring volume by sector, company size and location over time.

### 🚀 How do I use LinkedIn Jobs Scraper?

1. Click **Try for free**.
2. Enter what you are searching for and where, for example `software engineer` in `London`.
3. Add any filters you want, such as date posted, workplace type or company size.
4. Cap the run with `maxResults`, then click **Start**.
5. Download the results as JSON, CSV or Excel, or pull them from the API.

### ⬇️ Input

```json
{
  "keywords": "software engineer",
  "location": "London",
  "maxResults": 100
}
```

Set at least one of `keywords` or `location`.

| Field | Type | Default | What it does |
|---|---|---|---|
| `keywords` | string | | Job title or keywords |
| `location` | string | | City, region or country |
| `datePosted` | string | `any` | Any time, past 24 hours, past week or past month |
| `workplaceType` | string | `any` | On site, remote or hybrid |
| `jobType` | string | `any` | Full time, part time, contract, temporary, internship or volunteer |
| `experienceLevel` | string | `any` | Internship through executive |
| `sortBy` | string | `relevance` | Relevance or most recent |
| `requireSalaryInfo` | boolean | `false` | Only jobs that state pay or an NHS band |
| `maxResults` | integer | `100` | Hard cap on billable results |

Many more filters are available including company size, industry, applicant count and CV keyword matching, see the input schema in the Console.

#### Company filters

| Field | Description |
|---|---|
| `companyInclude` | Only these employers. Suffixes like Ltd and PLC are ignored when matching |
| `companyExclude` | Drop these employers |
| `excludeRecruitingAgencies` | Drop agency listings and keep direct employers |
| `enrichCompanyData` | Add employee count, followers, industry, website and location |
| `companySizeMin` / `companySizeMax` | Filter by employee count |
| `companyFollowersMin` / `companyFollowersMax` | Filter by LinkedIn following |

Company pages are fetched once per company and cached within a run, so a hundred jobs at one employer costs one extra request, not a hundred.

#### Job filters

| Field | Description |
|---|---|
| `excludeJobTitles` | Drop titles containing any of these words |
| `underTenApplicants` | LinkedIn's own low competition filter, applied server side |
| `maxApplicants` | Only jobs with an exact count at or below this |
| `easyApplyOnly` | Apply without leaving LinkedIn, applied server side |
| `requireSalaryInfo` | Only jobs that state pay or an NHS band |

#### CV matching

Paste your skills into `resumeKeywords` and every job is scored on how many appear in the title and description. You get `keywordMatchScore` as a percentage plus `matchedKeywords` and `unmatchedKeywords`, so you can see exactly why a job scored what it did. Set `minKeywordScore` to drop anything below a threshold.

### ⬆️ Output

#### Table view

Results arrive as a table you can sort and filter in the Console. There is a second view, Salary detail, that lines up the parsed and annualised pay figures for comparing listings against each other.

Every run also writes a `RUN_SUMMARY` to the key value store showing how many jobs were scanned, how many were kept, and exactly which filter rejected the rest. If a run returns nothing you can see why rather than guessing.

#### JSON

A typical row:

```json
{
  "title": "Product Manager",
  "company": "Example Group",
  "location": "Reading, England, United Kingdom",
  "postedAt": "2026-08-07T00:00:00.000Z",
  "url": "https://uk.linkedin.com/jobs/view/product-manager-4448545238",
  "jobId": "4448545238",
  "companyUrl": "https://uk.linkedin.com/company/examplegroup"
}
```

Download it from the run as JSON, CSV or Excel, or read it straight from the API.

### Two things this does that others do not

**Salary you can actually compare.** Pay is published as hourly, daily or annual with no consistency. Every job gets `salaryPeriod` plus `salaryAnnualisedFrom` and `salaryAnnualisedTo`, so one column sorts correctly across the whole result set.

**UK healthcare pay bands.** NHS roles state pay as "Band 5" rather than a number, so generic scrapers report no salary at all. On a sample of 20 UK nursing jobs, numeric salary extraction found pay on 1. Adding band detection took that to 9. You get `payBand` and `payScheme` as facts. The actor deliberately does not convert bands into salary figures, because published Agenda for Change tables disagree with each other materially and inventing a number would be worse than leaving it out.

### Honest limits

**1,000 results per search.** LinkedIn's own ceiling, not a bug. Verified: offset 975 returns jobs, offset 1000 returns an error. Narrow the search with filters or date ranges to get past it.

**Applicant counts are bucketed below 25.** LinkedIn will filter to low competition jobs server side via `underTenApplicants`, but its guest view still reports "be among the first 25 applicants" rather than the exact figure. So you can select those jobs, you just cannot see the true number. The `applicantsBasis` field marks every count as `exact`, `upper-bound` or `minimum` so you always know which you are looking at.

**Salary is often absent.** LinkedIn does not require employers to publish pay, and most do not. `requireSalaryInfo` filters to those that do.

### Empty results are not a failure

If nothing passes your filters the run finishes successfully with an empty
dataset, and `RUN_SUMMARY` holds the rejection breakdown, so you can see which
filter ate everything.

An earlier version threw an error here. That recorded a failed run against the
actor's success rate for what was a filter choice, and it discarded the
diagnostic you needed.

### When a filter cannot be evaluated

If a detail page fails to load, the job's function, industry and applicant count
are unknown. By default those jobs are **kept**, so a network problem is never
mistaken for a filter decision. `detailFailures` in `RUN_SUMMARY` tells you how
often it happened.

Set `strictFilters` to reject them instead.

### Speed

`concurrency` controls how many detail and company pages are read at once,
default 6, maximum 10. It does not change how many requests are made, only how
long you wait. Ten detail pages take under a second at the default, against
roughly eight seconds one at a time.

### Salary

Salary is read from the listing's own pay field, or from a labelled pay section
on the detail page. It is never extracted from the body of a job description: a
project budget or a revenue figure in the copy would otherwise become the
salary and then feed `salaryMin` filtering. Where there is no pay information,
`hasSalary` is false rather than a number being guessed.

### 💰 How much does it cost?

Three events, because the cost of a run is not proportional to how many jobs
survive your filters.

| Event | Charged when |
|---|---|
| `job-detail-fetched` | A job detail page is read |
| `company-enriched` | A company page is read, once per company |
| `job-result` | A job passes every filter and is delivered |

Deep filtering asks this actor to read a thousand jobs to find fourteen. Under a
single per-result charge, the better your filters the worse the economics, which
is the wrong incentive on the one feature that sets this apart. Splitting the
events means you pay for the reading you asked for and the results you got, and
both are visible before you run.

Cheap filters, company name, title exclusions and agency exclusion, are applied
before any detail request, so they never cost you a fetch.

#### What a run actually costs

The listing shows the result price. That is the cheap end, and deep filtering
costs more, so here are both plainly:

| Run | Cost |
|---|---|
| 100 jobs, no detail filters | about $0.10 |
| 1,000 scanned, 14 delivered after salary, applicant and keyword filters | about $2.01 |

The second run reads a thousand detail pages to find fourteen jobs. That reading
is the work you asked for and it is charged at `job-detail-fetched`, not hidden
in the result price. Filters that need no detail page, company name, title
exclusions and agency exclusion, are applied first and cost nothing.

If you want the cheap end, leave the detail filters off.

### 🔌 Integrations

Send results straight to Google Sheets, Slack, Airtable, Zapier, Make or your own webhook using [Apify integrations](https://docs.apify.com/platform/integrations). You can also trigger a run whenever something happens in another tool.

### 🔗 Using LinkedIn Jobs Scraper with the Apify API

```bash
curl -X POST "https://api.apify.com/v2/acts/spookyweb~linkedin-jobs/run-sync-get-dataset-items?token=YOUR_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"keywords": "software engineer", "location": "London", "maxResults": 100}'
```

Or with the Apify client:

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

const client = new ApifyClient({ token: 'YOUR_TOKEN' });
const run = await client.actor('spookyweb/linkedin-jobs').call({
  keywords: 'software engineer',
  location: 'London',
  maxResults: 100,
});
const { items } = await client.dataset(run.defaultDatasetId).listItems();
```

Full detail is in the [Apify API reference](https://docs.apify.com/api/v2), and every run is also callable from the [Python](https://docs.apify.com/api/client/python) and [JavaScript](https://docs.apify.com/api/client/js) clients.

### ❓ FAQ

#### Do I need a LinkedIn account or a session cookie?

No, and that is the point. This reads the public guest job pages, so there is no login step, no cookie to paste and no session of yours to expire or get flagged. You also do not need a proxy.

#### How many jobs can I get from one search?

Up to 1,000, which is LinkedIn's own ceiling rather than a limit set here. Past that the guest endpoint returns a 400, which the run treats as a normal end and finishes successfully with what it has. To cover more of a market, split the search by location, by job title or by date range.

#### What is CV keyword matching?

Put your skills into `resumeKeywords` and every job is scored on how many of them appear in its title and description. The row carries `keywordMatchScore` as a percentage plus `matchedKeywords` and `unmatchedKeywords`, so the score is auditable. `minKeywordScore` drops anything below a threshold.

#### Does it get applicant counts?

Yes, in `applicants`, with `applicantsBasis` saying whether the figure is `exact`, an `upper-bound` or a `minimum`. Below 25 applicants LinkedIn's guest view reports a bucket rather than a number, so you can still select those jobs with `underTenApplicants`, you just cannot see the true count.

#### What does enrichCompanyData add?

Employee count, followers, industry, website, description and location for the hiring company. Company pages are read once per company and cached within the run, so a hundred jobs at one employer costs one extra request rather than a hundred.

#### Why is a job kept when a lookup fails?

Because a network problem is not a filter decision. If a detail page will not load, the job's function, industry and applicant count are unknown, and dropping it would quietly turn an outage into a filtered result. Those jobs are kept by default and counted in `detailFailures`. Set `strictFilters` to reject them instead.

### ⚖️ Is it legal to scrape LinkedIn?

This reads LinkedIn's public guest job pages, the ones anyone can see without an account. It never logs in, it never uses a cookie, and it never touches anything behind authentication. Job adverts are business information published by employers to be seen.

Scraping public data is lawful in the UK, the EU and the US, and the hiQ v LinkedIn line of cases went to public data specifically. If you go on to combine this data with personal data, that is on you to handle under GDPR. Apify's [ethical scraping guide](https://blog.apify.com/is-web-scraping-legal/) covers the wider picture.

### 👍 Your feedback

Found a bug, or want a field that is not here yet? Open an issue on the Actor's Issues tab. Requests that make the data more useful get built, and problems get fixed quickly.

### 🔎 You might also like

| Actor | What it does |
|---|---|
| [Indeed Jobs Scraper](https://apify.com/spookyweb/indeed-jobs) | Indeed listings with parsed salary figures, past the 15 job sign in wall |
| [Reed Jobs Scraper](https://apify.com/spookyweb/uk-jobs-reed) | UK jobs from Reed with applicant counts and annualised salaries |
| [Totaljobs Scraper](https://apify.com/spookyweb/uk-jobs-totaljobs) | UK jobs from Totaljobs with duplicate agency repostings removed |

# Actor input Schema

## `keywords` (type: `string`):

Job title or keywords, for example software engineer.

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

City, region or country, for example London.

## `datePosted` (type: `string`):

How recently the job was posted. Use day, week or month to restrict to that window, or any to return everything LinkedIn still lists.

## `workplaceType` (type: `string`):

Where the work is done, as LinkedIn classifies it: onsite, remote or hybrid. Use any to include all three.

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

The contract basis of the role, for example full-time, contract or internship. Use any to include every basis LinkedIn lists.

## `experienceLevel` (type: `string`):

Seniority band as LinkedIn defines it, from entry and associate up to director and executive. Use any to include every level.

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

Result ordering. relevance uses LinkedIn own ranking, date returns the most recently posted first, which is what you want when monitoring for new listings.

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

Hard cap. LinkedIn serves at most 1000 per search. Each job is one billable result.

## `companyInclude` (type: `array`):

Keep only jobs from these employers. Company suffixes and case are ignored.

## `companyExclude` (type: `array`):

Employer names to drop from the results, matched on the company name as LinkedIn displays it. Useful for filtering out staffing agencies or your own listings.

## `excludeRecruitingAgencies` (type: `boolean`):

Drop listings posted by agencies rather than the employer. Useful because LinkedIn carries far more direct employer posts than agency ones.

## `enrichCompanyData` (type: `boolean`):

Fetch each company page for employee count, followers, industry, website and location. Costs one extra request per company, cached within a run.

## `companySizeMin` (type: `integer`):

Only companies with at least this many employees. Enables company enrichment.

## `companySizeMax` (type: `integer`):

Only companies with at most this many employees.

## `companyFollowersMin` (type: `integer`):

Only companies with at least this many followers.

## `companyFollowersMax` (type: `integer`):

Only companies with at most this many followers.

## `excludeJobTitles` (type: `array`):

Drop jobs whose title contains any of these words.

## `maxApplicants` (type: `integer`):

Only jobs with an exact applicant count at or below this. Jobs showing "first 25" are excluded because their real count is unknown.

## `requireSalaryInfo` (type: `boolean`):

Only jobs that disclose a salary or an NHS pay band. Enables detail fetching.

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

Paste your skills. Each job is scored on how many appear in the title and description, returned as keywordMatchScore with matched and unmatched lists. Enables detail fetching.

## `minKeywordScore` (type: `integer`):

Drop jobs scoring below this percentage against your keywords.

## `fetchDetails` (type: `boolean`):

Fetch every job detail page for seniority, employment type, function, industries, description, applicant count and apply type. Turned on automatically when a filter needs it.

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

Optional. The guest endpoints need no proxy, but one helps at high volume from a single IP.

## `underTenApplicants` (type: `boolean`):

Uses LinkedIn own low-competition filter, applied server side so it costs no extra requests. Note LinkedIn does not reveal exact counts below 25, so results show "first 25" rather than the true figure.

## `easyApplyOnly` (type: `boolean`):

Only jobs you can apply to without leaving LinkedIn. Applied server side by LinkedIn.

## `jobFunctionInclude` (type: `array`):

Keep only jobs whose LinkedIn job function matches, for example Engineering or Finance.

## `jobIndustryInclude` (type: `array`):

Keep only jobs in these industries, named as LinkedIn labels them. Leave empty to accept every industry.

## `jobIndustryExclude` (type: `array`):

Drop jobs in these industries, named as LinkedIn labels them. Applied after the include list, so an industry named in both is excluded.

## `salaryMin` (type: `integer`):

Minimum annual equivalent pay. Hourly and daily rates are annualised first so they are compared fairly.

## `salaryMax` (type: `integer`):

Upper bound on pay, as an annual equivalent in the currency the job advertises. Jobs that publish no salary are kept, because most listings omit it.

## `concurrency` (type: `integer`):

How many detail and company pages to fetch at once. Does not change how many requests are made, only how long the run takes.

## `strictFilters` (type: `boolean`):

By default a job is kept when a lookup fails, so a network problem is not mistaken for a filter decision. Turn this on to reject those instead.

## Actor input object example

```json
{
  "keywords": "software engineer",
  "location": "London",
  "datePosted": "any",
  "workplaceType": "any",
  "jobType": "any",
  "experienceLevel": "any",
  "sortBy": "relevance",
  "maxResults": 100,
  "excludeRecruitingAgencies": false,
  "enrichCompanyData": false,
  "requireSalaryInfo": false,
  "fetchDetails": false,
  "underTenApplicants": false,
  "easyApplyOnly": false,
  "concurrency": 6,
  "strictFilters": false
}
```

# Actor output Schema

## `results` (type: `string`):

One row per item: job listings with company and salary data.

## `runSummary` (type: `string`):

Counts and stop reason for the run.

# 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 = {
    "keywords": "software engineer",
    "location": "London"
};

// Run the Actor and wait for it to finish
const run = await client.actor("spookyweb/linkedin-jobs").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 = {
    "keywords": "software engineer",
    "location": "London",
}

# Run the Actor and wait for it to finish
run = client.actor("spookyweb/linkedin-jobs").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 '{
  "keywords": "software engineer",
  "location": "London"
}' |
apify call spookyweb/linkedin-jobs --silent --output-dataset

```

## MCP server setup

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

```

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/cCeqnn6O0RY7xuywm/builds/6gPZhYYIofCZSnrYc/openapi.json
