# LinkedIn Jobs Scraper with Applicant Counts (No Cookies) (`northbell/linkedin-jobs-applicants-scraper`) Actor

Scrape public LinkedIn job posts with their applicant counts — then keep the series, so you also get how fast applicants arrive and how long each job stays open. No login, no cookies.

- **URL**: https://apify.com/northbell/linkedin-jobs-applicants-scraper.md
- **Developed by:** [Northbell](https://apify.com/northbell) (community)
- **Categories:** Jobs, Lead generation
- **Stats:** 2 total users, 1 monthly users, 100.0% runs succeeded, 0 bookmarks
- **User rating**: No ratings yet

## Pricing

Pay per event

This Actor is paid per event. You are not charged for the Apify platform usage, but only a fixed price for specific events.

Learn more: https://docs.apify.com/actors/running/actors-in-store.md#pay-per-event

## What's an Apify Actor?

Actors are web data automations that power AI and operations. They run on the Apify platform to scrape websites, process data, connect APIs, and automate workflows.
In Batch mode, an Actor accepts a well-defined JSON input, performs an action which can take anything from a few seconds to a few hours,
and optionally produces a well-defined JSON output, datasets with results, or files in key-value store.
In Standby mode, an Actor provides a web server which can be used as a website, API, or an MCP server.
Actors are written with capital "A".

## How to integrate an Actor?

If asked about integration, you help developers integrate Actors into their projects.
You adapt to their stack and deliver integrations that are safe, well-documented, and production-ready.
The best way to integrate Actors is as follows.

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

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

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

# README

## LinkedIn Jobs Scraper with Applicant Counts

Scrape public LinkedIn job posts — title, company, location, exact posting date, seniority, employment type, description, and **how many people have applied**.

Then keep running it, and you get the thing a single scrape cannot give you: **how fast those applicants are arriving.**

### The number that goes stale in an hour

A job post showing `141 applicants` tells you almost nothing on its own. `141 applicants` on a job posted **yesterday** is a stampede. `141 applicants` on a job posted **five weeks ago** is a role nobody wants.

Same number. Opposite meaning. The difference is time, and LinkedIn does not keep it for you.

**Applicant counts cannot be back-filled.** There is no endpoint that tells you what a job's count was last Tuesday. If you did not record it that day, that day is gone. This Actor records it and keeps the series:

```
job 4419969671   Senior Software Engineer — General Motors

  Aug 20    21 applicants     posted
  Aug 21    58 applicants
  Aug 22    97 applicants
  Aug 24   141 applicants     →  30 per day, still open, 4 days
```

### What you get

Every run appends to your dataset. Rows are tagged by `type`.

**`job`** — one row per job.

| field | meaning |
|---|---|
| `applicants` | how many have applied right now |
| `applicantsPerDay` | the rate, measured across your own observations |
| `applicantsPerDayReliable` | `false` when the figure cannot be trusted — see below |
| `applicantsAddedSinceLastRun` | new applicants since you last looked |
| `postedOn` | the exact date from LinkedIn's own `datetime` attribute, not "3 days ago" |
| `daysOpen` | how long it has been live |
| `closed` / `closedOn` | it stopped accepting applications, and when you first saw that |
| `isRepost`, `timesPosted`, `daysSincePreviousPosting` | the same company advertising the same role again |
| `observations` | how many times you have sampled this job |
| `title`, `company`, `companySlug`, `location`, `seniority`, `employmentType`, `jobFunction`, `industries`, `description`, `salaryMentioned` | the posting itself |

**`vanished`** — a job that was in your results last run and is not there now, with its last known applicant count. A job disappearing silently is indistinguishable from a broken scraper, so it gets a row.

**`error`** — anything that failed, written where you will actually see it.

### Three questions this answers that a snapshot cannot

**"Is it worth applying?"** — 141 applicants in one day means you are competing with a crowd. 12 applicants in fourteen days means the posting is quiet and your application will actually be read.

**"Is this company really hiring?"** — a role that closes in four days was filled. A role reposted three times in six months was not. `isRepost` and `timesPosted` separate genuine demand from a job ad that has become furniture.

**"How fast is this market moving?"** — applicants-per-day, aggregated by company, seniority or region, is a hiring-velocity signal you own outright, because you are the one who recorded it.

### Numbers this Actor refuses to give you

LinkedIn stops counting precisely at a threshold and starts saying `Over 200 applicants`. Subtracting one rounded figure from another produces a rate that looks precise and is not.

So: when either end of an interval is capped, `applicantsPerDayReliable` is `false` and `applicantsPerDayNote` says `capped-by-linkedin-so-this-is-a-lower-bound`. The same applies on a job's first observation (`first-observation` — one point has no slope), within the first half-day (`observed-for-less-than-half-a-day` — dividing by a tiny interval explodes), and when a count goes *down* (`count-went-down`).

A rate without that flag is a rate that will eventually lie to you.

### No login. Not as a policy — as a property of the code.

This Actor never signs in, never asks you for a session cookie, and never sends one. It reads LinkedIn's public guest endpoints, the same pages an anonymous visitor sees.

That is enforced, not promised:

- The request headers are a **frozen object** with no `Cookie` and no `Authorization` field, and nothing can add one at runtime.
- A guard rejects any attempt to attach a credential header, and **the input schema refuses any field** whose name looks like `cookie`, `session`, `token`, `auth` or `password`.
- Unit tests assert all of the above, including that no session identifier ever reaches your dataset.

If a competing tool asks you to paste your `li_at` cookie, it is operating your LinkedIn account on your behalf. This one cannot, by construction.

### Two more things it gets right

**A failed fetch becomes a row, not a log line.** Nobody reads run logs. If the listing endpoint refuses, you get an `error` row in the dataset, and the run is marked failed when a whole requested feature came back empty. A green run with an empty dataset is the worst outcome for anything you check once a day.

**A closed job is data, not a failure.** When a job's page returns 404 the Actor records `closed: true` with the date — that is the end of the job's life, which is exactly what you were measuring. It does not count as an error and does not fail the run.

### Input

```json
{
  "keywords": "software engineer",
  "location": "United States",
  "maxJobs": 50,
  "datePosted": "past-24-hours",
  "fetchDetails": true,
  "onlyTrackedJobs": false,
  "maxRequestsPerMinute": 20
}
```

#### Sizing a run

Ten jobs per listing request, plus one request per job for the applicant count.

`requests = ceil(maxJobs / 10) + maxJobs`

50 jobs is 55 requests, about three minutes at the default rate.

Set `fetchDetails: false` for a listing-only run at a tenth of the requests — you lose the applicant count, which is the point of this Actor, but it is there if you only want the postings.

#### Daily runs

Set `onlyTrackedJobs: true`. New jobs are still listed, but only jobs you already have a history for get their detail page fetched — so you pay to keep watching the series you are building, not to re-scrape the whole market every morning.

### What you pay for

Pay per event, charged only for results actually delivered:

| event | when |
|---|---|
| Actor start | once per run |
| Job listed | one job from the listing |
| Job detail | one job's applicant count, seniority, type and description recorded |

**A failed fetch is never charged.** If the detail page errors, you get the listing row and the listing charge only. You are paying for data, not attempts.

### Limits worth knowing

- **Ten jobs per listing page.** Deep result sets thin out; LinkedIn does not serve unlimited pages to guests.
- **Applicant counts are capped** above a threshold. See above.
- **`daysOpen` uses the posting date when LinkedIn gives one**, and falls back to the date you first saw the job. `daysOpenBasis` tells you which, every time.
- The rate-limit budget persists in a key-value store, so overlapping runs of this Actor share one budget rather than stacking up.

### On data and privacy

This Actor collects **job postings**, not people. It does not read, store or return applicant identities, profiles, names or contact details — the applicant figure is a count and nothing else.

Job descriptions pass through to your dataset but are **never written to the Actor's own history**. The persistent store holds numbers and identifiers only: job ids, applicant counts, dates.

Company logos are not redistributed.

### Storage

History lives in a named key-value store, `linkedin-jobs-history`, so it survives between runs. Deleting it resets the baselines — every job reports as a first observation again, and velocity goes quiet until it has two samples.

### Running locally

```bash
npm install
npm test          # 34 unit tests, no network, including the no-login guarantees
```

# Actor input Schema

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

What to search for, exactly as you would type it into LinkedIn's job search.

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

City, region or country as LinkedIn spells it — "United States", "Berlin, Germany", "Remote".

## `maxJobs` (type: `integer`):

10 jobs per listing request, plus one request per job for the applicant count. 50 jobs is about 55 requests.

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

Narrowing to the last 24 hours is the usual choice for a daily run.

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

The applicant count, seniority, employment type and description live on the job page, so this costs one extra request per job. Turn it off for a cheap listing-only run.

## `onlyTrackedJobs` (type: `boolean`):

For daily runs. New jobs are still listed, but their detail page is skipped, so you only pay to keep watching the jobs you already have a history for.

## `geoId` (type: `string`):

Optional. LinkedIn's internal location id, taken from a job-search URL. More precise than a location name when you need one exact region.

## `maxRequestsPerMinute` (type: `integer`):

Kept polite by default. The budget is shared across your runs of this Actor, so parallel runs do not stack up.

## Actor input object example

```json
{
  "keywords": "software engineer",
  "location": "United States",
  "maxJobs": 50,
  "datePosted": "any",
  "fetchDetails": true,
  "onlyTrackedJobs": false,
  "maxRequestsPerMinute": 20
}
```

# Actor output Schema

## `all` (type: `string`):

Every row: job, vanished, and error.

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

One row per job: title, company, location, exact posting date, applicant count, seniority, employment type and description.

## `velocity` (type: `string`):

How fast applicants are arriving on each job, how long it has been open, and whether it has closed.

## `reposts` (type: `string`):

Roles the same company has advertised more than once — the ones that are not getting filled.

# 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": "United States"
};

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

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

```

## MCP server setup

```json
{
    "mcpServers": {
        "apify": {
            "type": "http",
            "url": "https://mcp.apify.com/?tools=fetch-actor-details,northbell/linkedin-jobs-applicants-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/W4NET8nwOOZvEju5n/builds/h9T5Xm54HBUEoFrfq/openapi.json
