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

LinkedIn job postings by keyword, location or company - no login, no cookies. Filter by date, experience, job type and remote. Incremental mode returns only NEW or CLOSED postings per run. Full description, seniority, employment type and industries included. No browser.

- **URL**: https://apify.com/hridayrungta/linkedin-jobs-scraper.md
- **Developed by:** [Hriday Rungta](https://apify.com/hridayrungta) (community)
- **Categories:** Jobs, Lead generation
- **Stats:** 2 total users, 1 monthly users, 100.0% runs succeeded, 0 bookmarks
- **User rating**: 5.00 out of 5 stars

## 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 — search, company postings and change alerts

**Point it at a keyword search or a company, and get one row per job posting — no login, no
`li_at` cookie, no session of any kind.** Turn on incremental mode and every run after the first
returns only postings that are **new** or have gone **closed** since the previous run, tracked per
search and per company in a key-value store on your own Apify account.

**From $0.50 per 1,000 rows in mode "all", $2.00 per 1,000 in mode "changes".** No start fee, no
platform usage passed through.

***

### What you get

One row per job posting:

| | |
|---|---|
| `jobId`, `title`, `permalink` | the posting and a direct link to it |
| `companyName`, `companyUrl`, `location` | who's hiring and where |
| `employmentType`, `seniorityLevel`, `jobFunction`, `industries` | LinkedIn's own posting metadata |
| `descriptionText` | the full job description, not a preview |
| `postedIso`, `postedText`, `applicantsText`, `activelyHiring` | when it went up, how many have applied, and LinkedIn's own "actively hiring" badge |
| `sourceType`, `sourceValue` | which search or company found it |

In mode "changes", every row also carries `changeType` (`new` or `closed`), `firstSeen`, `lastSeen`
and `daysLive` — so a schedule builds a clean history of when a posting appeared and when it came
down, with no duplicate rows for postings that are just still open.

### Search by keyword, by company, or both

`searches` takes any number of `{ keywords, location }` pairs — leave either blank for "any".
`companyUrls` takes LinkedIn company page URLs or bare slugs (`stripe`, or the full
`https://www.linkedin.com/company/stripe/`); every open posting from that company comes back, with
zero overlap against a keyword search — company postings and keyword-search postings are fetched
through separate endpoints, not filtered out of the same result set. Mix both in one run: 2 searches
and 3 companies is 5 independent sources, each with its own incremental tracking.

#### Narrowing a search

`datePosted` (any time / past 24 hours / past week / past month), `experienceLevel`, `jobType` and
`remote` are sent straight to LinkedIn's own search filters, so they narrow the result set
server-side rather than discarding rows you already paid to fetch. `titleInclude` / `titleExclude`
filter by keyword afterwards — exclude always wins over include.

`salaryMin` is **best-effort, and deliberately conservative**: LinkedIn does not expose a
structured salary field on every posting — most say nothing about pay at all — so a posting is
excluded only when a specific figure was found in its description text and that figure is below
your minimum. A posting that mentions no salary is always kept, never guessed away.

### Incremental "changes" mode

| Input | What it does |
|---|---|
| `mode` | `"all"` (default): every matching posting, every run. `"changes"`: only postings NEW or CLOSED since this source's last run. |
| `firstRunSince` | how far back a source's **first** run in changes mode reaches before treating a posting as "new". Default `30 days`. Set `"all"` to baseline nothing and report every currently-open posting as new on run one. |
| `stateStoreName` | the key-value store on **your** account holding the known-postings set per source. Default is created for you. |
| `stateNamespace` | optional extra prefix, for several independent tracking sets inside one store. |

#### Why this isn't just a high-water mark

A job posting isn't append-only the way a forum post is — it can **close**, and LinkedIn's search
gives no "this posting is now closed" signal of its own. So instead of a newest-first watermark,
this Actor keeps the **full set of posting ids** it has seen for each search or company, and diffs
a fresh fetch against it: an id missing from the fresh fetch is reported closed, an id present in
both is left alone (no charge, no row), and an id that's new to the set is reported new. That's what
makes "changes" mode able to tell you a posting came down, not just that nothing new appeared.

#### The first run, spelled out

A source with no stored set yet takes every **currently open** posting matching `firstRunSince` as
its baseline "new". By default that's the last 30 days — so a first run on a broad search doesn't
bill you for every posting a company has ever had open. Every later run compares against the stored
set instead, and only reports genuine changes.

#### Safety rails

- **A run cut short never reports a false "closed".** If a page walk is truncated by a budget or a
  page cap before it finishes a source, the run cannot tell "this posting is gone" apart from "this
  posting is just further down a page we didn't reach" — so closed-posting detection is skipped
  entirely for that source on that run, and the log says so plainly.
- **Your own filters never affect what counts as known.** `titleInclude`, `titleExclude` and
  `salaryMin` only decide what gets emitted as a row; the full fetched set is still tracked, so a
  posting your filters hide this run is never wrongly reported "closed" the moment it stops
  matching a filter.
- **State is namespaced to you.** Keys are prefixed from your own Apify user id, so two buyers'
  tracked sets can never cross, even if they happen to track the identical search.

### What it needs, and what it never does

**A plain Apify Proxy is enough — this Actor deliberately does not request the UNBLOCKER group.**
Measured directly: 20 out of 20 job-detail page requests answered HTTP 200 on a bare datacenter
connection with zero cookies, no `999` anti-bot blocks. Job postings are public listings, and this
Actor is priced accordingly — about a tenth of what an unblocked request costs.

It never touches a personal profile page (`/in/<slug>`). Personal profiles were checked separately
during this Actor's development and found to be genuinely gated — blocked cold, and truncated to a
sign-in-wall teaser even through a paid unblocking proxy — which is exactly why this Actor scrapes
only job postings: a public listing, not a personal record, and a surface that tolerates a plain
proxy because LinkedIn treats it differently from a profile page. No login, no `li_at` cookie, no
session of any kind, ever.

### Input at a glance

| Input | Default | Notes |
|---|---|---|
| `searches` | one example search | `{ keywords, location }` pairs; either can be blank |
| `companyUrls` | `[]` | a full company URL or a bare slug |
| `mode` | `all` | `all` or `changes` |
| `datePosted` | `any` | `any`, `past24Hours`, `pastWeek`, `pastMonth` |
| `experienceLevel` | any | internship / entry / associate / mid-senior / director / executive |
| `jobType` | any | full-time / part-time / contract / temporary / volunteer / internship / other |
| `remote` | any | on-site / remote / hybrid |
| `salaryMin` | `0` | best-effort, see above — never excludes a posting that mentions no salary |
| `titleInclude` / `titleExclude` | `[]` | keyword filters on the title; exclude wins |
| `maxResults` | `100` | cost cap — you're billed once per row saved |
| `detailConcurrency` | `5` | job-detail pages fetched in parallel |
| `firstRunSince` | `30 days` | mode "changes" only; or `all` for no baseline suppression |
| `stateStoreName` / `stateNamespace` | default / auto | mode "changes" only |

### Billing

Pay per event, charged as each row is saved, with **no start fee** and no charge for platform
usage:

| Event | Price | When |
|---|---|---|
| `job-row` | $0.0005 | one posting row, mode "all" |
| `change-row` | $0.002 | one NEW or CLOSED posting row, mode "changes" |

Set `maxResults` as your cost cap. When a charge limit is reached the run stops cleanly, keeps
everything already saved, and leaves the tracked state where it was so nothing is re-billed on the
next run.

# Actor input Schema

## `searches` (type: `array`):

One or more { keywords, location } pairs. Leave a field blank for "any". At least one search or company URL is required.

## `companyUrls` (type: `array`):

LinkedIn company page URLs or slugs (e.g. "https://www.linkedin.com/company/stripe/" or just "stripe"). Every open posting from that company is returned.

## `mode` (type: `string`):

"all": every matching posting, every run. "changes": only postings that are NEW or CLOSED since your last run (tracked per search/company in your own account).

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

Only postings first posted within this window.

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

Restrict to a single LinkedIn experience-level filter.

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

Restrict to a single employment type.

## `remote` (type: `string`):

Restrict to on-site, remote or hybrid postings.

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

Excludes a posting only when a parseable salary figure was found in its description AND it is below this number. LinkedIn does not expose a structured salary field on every posting, so this cannot catch postings that mention no figure at all.

## `titleInclude` (type: `array`):

Keep only postings whose title contains at least one of these keywords (case-insensitive). Leave empty for no restriction.

## `titleExclude` (type: `array`):

Drop any posting whose title contains one of these keywords (case-insensitive). Takes priority over titleInclude.

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

Upper bound on rows saved this run, across all sources combined.

## `detailConcurrency` (type: `integer`):

How many job-detail pages to fetch in parallel.

## `stateStoreName` (type: `string`):

Name of the key-value store (in YOUR account) that holds the known-postings set per source. Leave blank for the default.

## `stateNamespace` (type: `string`):

Advanced: an explicit namespace for the state keys, letting you keep several independent tracking sets in one store. Leave blank to namespace by your Apify user id automatically.

## `firstRunSince` (type: `string`):

On a source's first run in "changes" mode, only postings posted since this date/span are emitted as "new" (the rest are baselined silently, so a first run never bills a search's entire current listing). Accepts "30 days", "2026-08-01", or "all" for everything currently open.

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

Plain Apify Proxy is enough here - job postings do not need the UNBLOCKER group.

## Actor input object example

```json
{
  "searches": [
    {
      "keywords": "software engineer",
      "location": "United States"
    }
  ],
  "companyUrls": [],
  "mode": "all",
  "datePosted": "any",
  "experienceLevel": "",
  "jobType": "",
  "remote": "",
  "salaryMin": 0,
  "titleInclude": [],
  "titleExclude": [],
  "maxResults": 100,
  "detailConcurrency": 5,
  "stateStoreName": "",
  "stateNamespace": "",
  "firstRunSince": "30 days",
  "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 = {};

// Run the Actor and wait for it to finish
const run = await client.actor("hridayrungta/linkedin-jobs-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 = {}

# Run the Actor and wait for it to finish
run = client.actor("hridayrungta/linkedin-jobs-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 '{}' |
apify call hridayrungta/linkedin-jobs-scraper --silent --output-dataset

```

## MCP server setup

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