# LinkedIn Profile Scraper (`xtracto/linkedin-profile-scraper`) Actor

Extract public LinkedIn profiles without cookies or an account: name, headline, location, follower count, current employer, education, work history, plus the person's public posts and articles. HTTP-only, no login, no browser.

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

## Pricing

from $2.67 / 1,000 results

This Actor is paid per event and usage. You are charged both the fixed price for specific events and for Apify platform usage.
Since this Actor supports Apify Store discounts, the price gets lower the higher subscription plan you have.

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

## What's an Apify Actor?

An Actor is a serverless cloud program that runs on the Apify platform. It has two run modes.
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.

Apify vocabulary and the platform model are defined once, in the agent quickstart at https://apify.com/agents.md.

## 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.

Do not guess an integration path. Every one of them is in the agent quickstart at https://apify.com/agents.md: the Apify MCP server, Agent Skills with the Apify CLI, the JavaScript and Python clients, the REST API, and the account-free path for an agent with no human to sign in. It also carries the rule on stating cost before the first paid run.

For examples already wired to this Actor's own input schema, see the [API](#api) section below.

Each client library has reference documentation the quickstart does not restate: [JavaScript/TypeScript](https://docs.apify.com/api/client/js/docs.md) (`npm install apify-client`) and [Python](https://docs.apify.com/api/client/python/docs.md) (`pip install apify-client`).

# README

## LinkedIn Profile Scraper

Extract public LinkedIn profiles — **no cookies, no account, no browser**. Give it profile
URLs or slugs and it returns the person's name, headline, location, follower count, work
history, education, and the public posts and articles shown on their profile.

HTTP-only (`curl_cffi`), public data only. Nothing behind the login wall is touched.

### Input

| Field | Type | Default | Description |
|---|---|---|---|
| `profiles` | array | *required* | Profile URLs (`https://www.linkedin.com/in/williamhgates`) or bare slugs (`williamhgates`) |
| `includePosts` | boolean | `true` | Public posts embedded in the profile page, plus their activity IDs |
| `includeArticles` | boolean | `true` | LinkedIn articles authored by this person |
| `maxAttempts` | integer | `4` | Retries (fresh exit IP + different country host) before accepting a redacted render |
| `maxConcurrency` | integer | `4` | Profiles in parallel. Keep it low |
| `proxyConfiguration` | object | RESIDENTIAL | **Residential strongly recommended** |

### Output

One row per input, always — including failures, so downstream joins never silently lose a key.

```jsonc
{
  "_input": "https://www.linkedin.com/in/satyanadella",
  "_source": "S1-jsonld+html",
  "_scrapedAt": "2026-09-06T16:08:08Z",
  "_host": "uk.linkedin.com",      // which country host served this render
  "_proxyCountry": "GB",
  "_masked": false,                // true when LinkedIn redacted the work history
  "slug": "satyanadella",
  "profileUrl": "https://www.linkedin.com/in/satyanadella",

  // --- raw schema.org Person node, upstream field names kept verbatim ---
  "name": "Satya Nadella",
  "jobTitle": ["Chairman and CEO", "Member Board Of Trustees"],
  "description": "As chairman and CEO of Microsoft, I define my mission…",
  "address": { "@type": "PostalAddress", "addressLocality": "Redmond, Washington, United States" },
  "worksFor": [{ "name": "Microsoft", "url": "…/company/microsoft",
                 "member": { "startDate": "2014-02" } }],
  "alumniOf": [{ "name": "Starbucks", "member": { "startDate": 2017, "endDate": 2024 } }],

  // --- derived conveniences ---
  "followerCount": 12152113,
  "experienceEducationCards": [{ "text": "…", "logo": "…" }],
  "posts": [ /* schema.org DiscussionForumPosting nodes */ ],
  "activityIds": ["7502385664945111040"]   // feed into a post-detail run
}
```

Failure rows carry `_error` (`not_found`, `blocked`, `bad_input`, `unexpected_shape`) and
`_errorDetail`, and no payload.

### How it works

LinkedIn ships a `<script type="application/ld+json">` block with a schema.org `Person`
node on every public profile. That is the primary source — it survives redesigns that break
CSS selectors. The HTML pass only adds the rendered Experience/Education cards.

#### Redacted work history, and why it retries

LinkedIn does not treat every anonymous visitor the same. On some renders the work history
comes back as asterisks (`"jobTitle": ["********"]`); on others, for the same profile, it is
complete.

It is worth being precise about what drives that, because the obvious explanations are all
wrong. Measured on 2026-09-06, three profiles fetched under three proxy modes:

| Proxy | williamhgates | satyanadella | reidhoffman |
|---|---|---|---|
| Residential | complete | complete | **redacted** |
| Datacenter | complete | complete | complete |
| None | complete | **redacted** | **redacted** |

The same profiles flipped between redacted and complete across consecutive runs on the same
proxy mode. So it is **not** the profile, **not** the country subdomain, and **not** the proxy
type — it is decided per request.

That makes retrying the correct and only fix, with one catch: the retries have to be
genuinely independent. An earlier build walked nine country subdomains on a single shared
session and got a redacted render nine times in a row. Every attempt now gets its own exit
IP *and* its own cookie jar; the country subdomain is varied alongside it only to keep the
request coherent (a GB exit IP asking `uk.linkedin.com` is an ordinary visitor).

Measured effect on a 6-profile run with `maxAttempts: 6`:

| | Shared session, 9 hosts | Independent sessions, 6 attempts |
|---|---|---|
| Complete | 1 of 4 | **5 of 6** (4 on the first attempt) |

If every attempt is redacted the row is still emitted, with `_masked: true` and
`_warning: experience_masked_by_linkedin`. Name, headline, location and follower count are
correct on a redacted render — only parts of the work history are hidden.

#### Block detection

Two things are deliberately **not** used to detect a block:

- **Status code alone.** LinkedIn serves its guest sign-in wall as a `200` with a full-size
  body, and answers a throttled request with `999` (its own status code, not an HTTP one).
- **Body substrings.** Every LinkedIn page carries the LiX flag
  `data-recaptcha-v3-integration-lix-value`, so a substring test for `"captcha"` marks good
  renders as blocked. That false positive threw away an entire cloud run's worth of valid
  data before it was caught.

What is used: the **final URL** (a redirect to `/authwall`, `/checkpoint/`, `/uas/login`),
and a **positive data marker** — the presence of the `Person` node. A render without one is
the wall, whatever its status code or size.

#### TLS fingerprinting

LinkedIn's guest surface does **not** gate on JA3/JA4. The profile ladder
`chrome131` / `chrome146` / `firefox144` / `safari2601` was run against `/in/<slug>` from one
IP: all four returned an identical 200 with the same JSON-LD payload. The gate here is IP
reputation and request rate. `chrome146` is the default because it is the most common real
UA; rotation is deliberately not implemented because it buys nothing.

### Known limits

| Limit | Detail |
|---|---|
| Work history redaction | Decided per request. `maxAttempts: 6` cleared it for 5 of 6 profiles; raise it if you need higher completeness |
| No pagination of posts | `/in/<slug>/recent-activity/` redirects back to the profile for anonymous visitors. You get the posts embedded in the profile page, nothing deeper |
| Rate limiting is aggressive | A single IP is throttled to `999` within a few dozen requests. **Use a residential proxy**; a bare local run will produce `blocked` rows almost immediately |
| Contact details | Email and phone are never exposed to anonymous visitors and are not returned |
| Not covered | `company/<slug>/people/` and `company/<slug>/posts/` redirect to the login wall — employee lists are not obtainable this way |

### Related

- **LinkedIn Company Profile Scraper** — company overviews
- **LinkedIn Jobs Scraper** — public job postings via the guest jobs API

Use `activityIds` from this actor's output as input to a post-detail run.

# Actor input Schema

## `profiles` (type: `array`):

LinkedIn profile URLs (https://www.linkedin.com/in/williamhgates) or bare slugs (williamhgates). One row is emitted per entry, including for failures.

## `includePosts` (type: `boolean`):

Include the public posts embedded in the profile page, plus their activity IDs. Costs no extra request - the posts ship inside the same HTML.

## `includeArticles` (type: `boolean`):

Include LinkedIn articles authored by this person, as embedded in the profile page. Costs no extra request.

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

Profiles fetched in parallel. Keep this low - LinkedIn throttles aggressively and returns HTTP 999 to a hot IP.

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

Residential proxy is strongly recommended. LinkedIn rate-limits a single IP to HTTP 999 within a few dozen requests, and datacenter ranges are throttled sooner.

## `maxAttempts` (type: `integer`):

LinkedIn redacts the work history on a random subset of anonymous renders. Each retry uses a fresh exit IP and a different country subdomain until an unredacted render comes back. Raise for completeness, lower for cheaper runs.

## Actor input object example

```json
{
  "profiles": [
    "williamhgates",
    "https://www.linkedin.com/in/satyanadella"
  ],
  "includePosts": true,
  "includeArticles": true,
  "maxConcurrency": 4,
  "proxyConfiguration": {
    "useApifyProxy": true,
    "apifyProxyGroups": [
      "RESIDENTIAL"
    ]
  },
  "maxAttempts": 4
}
```

# Actor output Schema

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

One row per scraped record, stored in the run's default dataset.

# 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 = {
    "profiles": [
        "https://www.linkedin.com/in/williamhgates"
    ],
    "proxyConfiguration": {
        "useApifyProxy": true,
        "apifyProxyGroups": [
            "RESIDENTIAL"
        ]
    }
};

// Run the Actor and wait for it to finish
const run = await client.actor("xtracto/linkedin-profile-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 = {
    "profiles": ["https://www.linkedin.com/in/williamhgates"],
    "proxyConfiguration": {
        "useApifyProxy": True,
        "apifyProxyGroups": ["RESIDENTIAL"],
    },
}

# Run the Actor and wait for it to finish
run = client.actor("xtracto/linkedin-profile-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 '{
  "profiles": [
    "https://www.linkedin.com/in/williamhgates"
  ],
  "proxyConfiguration": {
    "useApifyProxy": true,
    "apifyProxyGroups": [
      "RESIDENTIAL"
    ]
  }
}' |
apify call xtracto/linkedin-profile-scraper --silent --output-dataset

```

## MCP server setup

```json
{
    "mcpServers": {
        "apify": {
            "type": "http",
            "url": "https://mcp.apify.com/?tools=fetch-actor-details,xtracto/linkedin-profile-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/1Jc3RX04V9NabuSvh/builds/YnlkRp1mr46sb6PQC/openapi.json
