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

Scrape LinkedIn job listings without a login or cookie: job titles, companies, company pages and logos, locations, posting dates, salary ranges, seniority, employment type and full descriptions for any keyword and place.

- **URL**: https://apify.com/muhammad4hmed/linkedin-jobs-scraper.md
- **Developed by:** [Muhammad Ahmed](https://apify.com/muhammad4hmed) (community)
- **Categories:** Jobs, Business
- **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

Scrape LinkedIn job listings without a login, a cookie or a browser. Give it a few search terms
and locations and it returns structured job rows: title, company, company page, logo, location,
posting date, and — optionally — salary range, seniority, employment type, industries, applicant
count and the full job description.

It reads LinkedIn's own logged-out job endpoints over plain HTTP. No account is used, nothing is
authenticated, and no personal profile data is touched.

### What you get

Every row is one job. Fill rates below are measured on a real Apify platform run
(100 jobs, queries `software engineer` and `registered nurse`, United States, residential proxy).

#### Always present — from the search results

| Field | Type | Fill | Notes |
|---|---|---|---|
| `jobId` | string | 100% | LinkedIn's numeric job posting id |
| `url` | string | 100% | Canonical `https://www.linkedin.com/jobs/view/<jobId>` |
| `title` | string | 100% | |
| `company` | string | 100% | Falls back to the plain-text name when the company has no LinkedIn page |
| `companyUrl` | string | null | 100% | `null` when the company has no LinkedIn page — not fabricated |
| `companyLogo` | string | null | 100% | `media.licdn.com` URL. Grey placeholder images are never emitted |
| `location` | string | 100% | As LinkedIn writes it: `New York, NY`, `Georgia, United States`, `Greater Kolkata Area` |
| `postedTimeText` | string | 100% | Relative, as shown: `3 days ago`, `15 hours ago` |
| `postedAt` | string | null | 100% | **Date only**, `YYYY-MM-DD`. See limitations |
| `isRemote` | boolean | 100% | **Inferred from the job title.** See limitations |
| `isNewBadge` | boolean | 100% | LinkedIn's "new posting" styling on the date |
| `benefitBadge` | string | null | 70-77% | Raw badge text: `Actively Hiring`, `Be an early applicant`, `Medical insurance +3 benefits` |
| `searchQuery` | string | 100% | The query that produced this row |
| `searchLocation` | string | 100% | The location that produced this row |
| `scrapedAt` | string | 100% | ISO timestamp |
| `detailFetched` | boolean | 100% | Whether the fields below were actually read |

#### Only with **Fetch job details** enabled

One extra request per job. Fill rates from the same platform run.

| Field | Type | Fill | Notes |
|---|---|---|---|
| `seniorityLevel` | string | null | 100% | About 90% of real jobs say `Not Applicable` |
| `employmentType` | string | null | 100% | `Full-time`, `Part-time`, `Internship`, `Contract`, `Volunteer`, `Other` |
| `jobFunction` | string | null | 100% | Single joined string, e.g. `Engineering and Information Technology` |
| `industries` | string | null | 100% | Single joined string, e.g. `Financial Services`. See limitations |
| `applicantCountText` | string | null | 72% | A bucket, not a number. See limitations |
| `salaryText` | string | null | 20% | Raw as displayed, e.g. `$123,500.00/yr - $150,000.00/yr`. Most jobs publish none |
| `easyApply` | boolean | null | 100% | `null` when the page carries no apply marker at all |
| `description` | string | null | 100% | Full text, ~3,900 characters on average |

Without **Fetch job details** these nine fields are `null` and `detailFetched` is `false`.

#### Example row

```json
{
  "jobId": "4453687841",
  "url": "https://www.linkedin.com/jobs/view/4453687841",
  "title": "Software Engineer II (Java)",
  "company": "JPMorganChase",
  "companyUrl": "https://www.linkedin.com/company/jpmorganchase",
  "companyLogo": "https://media.licdn.com/dms/image/v2/D4E0BAQGxpntCyRgsuA/company-logo_100_100/...",
  "location": "New York, NY",
  "postedTimeText": "3 days ago",
  "postedAt": "2026-08-13",
  "isNewBadge": false,
  "benefitBadge": "Actively Hiring",
  "isRemote": false,
  "seniorityLevel": "Not Applicable",
  "employmentType": "Full-time",
  "jobFunction": "Engineering and Information Technology",
  "industries": "Financial Services",
  "applicantCountText": "Over 200 applicants",
  "salaryText": "$123,500.00/yr - $150,000.00/yr",
  "easyApply": false,
  "description": "Job Description You're ready to gain the skills and experience needed to grow …",
  "detailFetched": true,
  "searchQuery": "software engineer",
  "searchLocation": "United States",
  "scrapedAt": "2026-08-17T04:49:15.076Z"
}
```

### Input

| Input | Type | Default | What it does |
|---|---|---|---|
| `searchQueries` | string\[] | *required* | Job titles or keywords. Each is run against every location |
| `locations` | string\[] | `["United States"]` | Free-text places LinkedIn geocodes, e.g. `Texas, United States`, `London, England, United Kingdom` |
| `maxResultsPerQuery` | integer | `100` | Jobs kept per query/location pair. Capped at 1000 (LinkedIn's own ceiling) |
| `remoteOnly` | boolean | `false` | Keep only remote jobs. Title-based — see limitations |
| `maxAgeDays` | integer | `0` | Only jobs posted within N days. `0` = any date. Genuine server-side filter |
| `easyApplyOnly` | boolean | `false` | Only jobs you apply to without leaving LinkedIn. Genuine server-side filter |
| `fetchJobDetails` | boolean | `false` | Fetch each job's page for the nine extra fields. One extra request per job, billed separately |
| `experienceLevel` | select | `any` | Seniority filter. **Requires `fetchJobDetails`** |
| `jobType` | select | `any` | Employment type filter. **Requires `fetchJobDetails`** |
| `maxItems` | integer | `0` | Hard cap across the whole run. `0` = no cap |
| `proxyConfiguration` | object | Apify residential | LinkedIn blocks datacenter IPs. Leave as is |

#### Minimal input

```json
{
  "searchQueries": ["software engineer"],
  "locations": ["United States"],
  "maxResultsPerQuery": 100
}
```

#### Everything on

```json
{
  "searchQueries": ["product manager", "data analyst"],
  "locations": ["United States", "Canada"],
  "maxResultsPerQuery": 200,
  "maxAgeDays": 7,
  "easyApplyOnly": true,
  "fetchJobDetails": true,
  "maxItems": 500
}
```

### Use cases

- **Job boards and aggregators** — pull fresh listings for a niche daily with `maxAgeDays: 1`.
- **Recruiting intelligence** — track which companies are hiring for which roles, and where.
- **Salary research** — `salaryText` gives real posted ranges for the ~20% of jobs that publish one.
- **Lead generation** — a company hiring 15 sales reps is a company with a budget.
- **Job-seeker automation** — `easyApplyOnly` plus `maxAgeDays: 1` produces a short, actionable list.
- **Labour-market analysis** — posting volume and `postedAt` over time, by title and geography.

### Limitations — read this before you rely on a field

This Actor reads LinkedIn's public, logged-out surface. That surface is narrower than the
logged-in site, and several things people expect simply are not published there.

**`isRemote` is inferred from the job title, not reported by LinkedIn.**
LinkedIn's logged-out pages contain no workplace-type field anywhere — not on the search card, not
on the job page. Its `f_WT` remote parameter is accepted and then silently ignored (a made-up
parameter returns byte-identical results). Matching the description text was tested and rejected:
every match in a 40-job sample was a false positive, such as "Fridays are remote-friendly ... we
prioritize in-person and hybrid work". So `remoteOnly` does two things instead: it appends
`remote` to the keywords sent to LinkedIn, which genuinely changes what is returned, and then keeps
only jobs whose **title** advertises remote work. On a platform test this produced 25 rows all
titled remote (`Backend Engineer (Remote Position)`, `Software Engineer (Fully Remote)`) against
0 remote-titled rows out of 50 in the unfiltered control. It is precise but it is not exhaustive —
a remote job whose title does not say so will be missed.

**`postedAt` is a date, never a time.** LinkedIn publishes `2026-08-13` and nothing finer anywhere
in the markup. If you need "15 hours ago" granularity, that lives in `postedTimeText`. `postedAt`
comes from the search card only, so it is present on every row regardless of `fetchJobDetails`.

**`applicantCountText` is a bucket, not a count.** LinkedIn only ever renders
`Be among the first 25 applicants` or `Over 200 applicants`. It is kept as the raw display string
on purpose. Do not parse it into a number — those are thresholds, not measurements. About 28% of
job pages show no applicant element at all.

**`industries` and `jobFunction` are single strings, not arrays.** LinkedIn joins multiple values
with " and ", and the individual values themselves contain " and "
(`Hospitals and Health Care`, `Computer and Network Security and Software Development`). Splitting
would be lossy and ambiguous, so the raw string is emitted unchanged.

**`seniorityLevel` is mostly uninformative.** About 90% of real listings report `Not Applicable`.
Filtering on `experienceLevel` therefore discards most results.

**`experienceLevel` and `jobType` are client-side filters and can be slow and expensive.**
LinkedIn's `f_E` and `f_JT` parameters are accepted by its public search and then ignored, so this
Actor reads the real values from each job page instead. That means every scanned card costs a
request whether or not it matches, and the run keeps scanning until it fills your quota or reaches
LinkedIn's 1000-result ceiling. A measured worst case: `jobType: "part-time"` on
`software engineer` scanned all 1000 reachable results, found 1 match, and took 11 minutes. Both
inputs refuse to run without `fetchJobDetails` rather than silently returning unfiltered results.

**`easyApply` is derived from the apply button's tracking attribute**, cross-checked against
LinkedIn's own Easy Apply filter (24 of 25 rows returned by that filter were detected as Easy
Apply, against 4 of 50 in the unfiltered control). The visible button text is "Apply" in both
cases, so it cannot be read from the label. A job page carrying neither marker yields `null`
rather than a misleading `false`.

**1000 results per query and location, hard.** LinkedIn refuses any result offset at or beyond
1000\. To collect more, split the search into narrower queries or more specific locations.

**Results are deduplicated by `jobId` across the whole run.** LinkedIn's result window drifts
between requests, so consecutive pages genuinely overlap. A job matching two of your queries is
emitted once, attributed to whichever query reached it first.

**`benefitBadge` is a multi-purpose badge.** It is not an Easy Apply flag and not an applicant
count. Only the raw string is emitted; no booleans are derived from it.

**Residential proxy is required.** LinkedIn blocks Apify's datacenter IPs. The default
configuration is correct — changing it will produce empty runs.

### Pricing

Pay per event:

| Event | Price | When |
|---|---|---|
| Actor start | $0.0005 | Once per run |
| Job scraped | $0.0004 | Per job that reaches the dataset |
| Job details | $0.0008 | Per job whose detail page was successfully read |

Blocked requests, empty pages and jobs discarded by a filter are never charged — you pay for rows
you actually receive. 1,000 jobs without details is $0.40; with full details it is $1.20.

# Actor input Schema

## `searchQueries` (type: `array`):

Job titles or keywords to search for, e.g. "software engineer" or "registered nurse". Each query is run against every location.

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

Free-text places LinkedIn geocodes, e.g. "United States", "Texas, United States", "London, England, United Kingdom". Do not enter "Remote" here — it geocodes to nothing coherent; use the Remote only toggle instead.

## `maxResultsPerQuery` (type: `integer`):

How many jobs to keep for each query/location pair. LinkedIn's public search stops serving results past offset 1000, so this is capped at 1000.

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

Keep only remote jobs. LinkedIn publishes no workplace-type field on its logged-out pages, so this appends "remote" to the search keywords and then keeps only jobs whose TITLE says remote ("Backend Engineer (Remote)", "... | Remote", work from home, WFH). It is a real filter and it cuts results hard — typically 5-15 rows per 80 cards scanned.

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

Only jobs posted within this many days. 0 means any date. This maps to LinkedIn's own date-posted filter and is verified to genuinely bound the results.

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

Only jobs you can apply to without leaving LinkedIn. This is a real server-side LinkedIn filter and needs no extra requests.

## `fetchJobDetails` (type: `boolean`):

Open each job page to add seniority level, employment type, job function, industries, applicant count, salary, Easy Apply flag and the full description. Costs one extra request per job and is billed as a separate event.

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

Filter by seniority. Requires "Fetch job details" — LinkedIn ignores its own experience-level parameter on public search, so this is applied to the value read from each job page. Warning: about 90% of real LinkedIn jobs report "Not Applicable", so any setting other than "any" discards most results.

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

Filter by employment type. Requires "Fetch job details" — LinkedIn ignores its own job-type parameter on public search, so this is applied to the value read from each job page.

## `maxItems` (type: `integer`):

Hard cap on total jobs across all queries and locations. 0 means no cap.

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

LinkedIn blocks Apify's datacenter IPs, so residential proxy is required. Leave this as it is unless you know what you are changing.

## Actor input object example

```json
{
  "searchQueries": [
    "software engineer"
  ],
  "locations": [
    "United States"
  ],
  "maxResultsPerQuery": 100,
  "remoteOnly": false,
  "maxAgeDays": 0,
  "easyApplyOnly": false,
  "fetchJobDetails": false,
  "experienceLevel": "any",
  "jobType": "any",
  "maxItems": 0,
  "proxyConfiguration": {
    "useApifyProxy": true,
    "apifyProxyGroups": [
      "RESIDENTIAL"
    ]
  }
}
```

# Actor output Schema

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

Every job scraped from LinkedIn, one row each.

## `csv` (type: `string`):

The same jobs as a spreadsheet-ready CSV file.

# 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 = {
    "searchQueries": [
        "software engineer"
    ],
    "locations": [
        "United States"
    ]
};

// Run the Actor and wait for it to finish
const run = await client.actor("muhammad4hmed/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 = {
    "searchQueries": ["software engineer"],
    "locations": ["United States"],
}

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

```

## MCP server setup

```json
{
    "mcpServers": {
        "apify": {
            "type": "http",
            "url": "https://mcp.apify.com/?tools=fetch-actor-details,muhammad4hmed/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/59gAeMZav7hbQPMp0/builds/QZ6szwGOKbu1MmFeE/openapi.json
