# LinkedIn Posts Scraper (`renovative_basilisk/linkedin-posts-scraper`) Actor

Searches public LinkedIn posts by keyword and returns post text, author, exact publication time, engagement counts, media and comments. No login, no cookies, no session tokens.

- **URL**: https://apify.com/renovative\_basilisk/linkedin-posts-scraper.md
- **Developed by:** [coding laporte](https://apify.com/renovative_basilisk) (community)
- **Categories:** Social media, Lead generation
- **Stats:** 2 total users, 1 monthly users, 100.0% runs succeeded, 0 bookmarks
- **User rating**: 5.00 out of 5 stars

## Pricing

from $1.75 / 1,000 post scrapeds

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

## Unofficial LinkedIn Posts Search Scraper

> ## ⚠️ This is an **UNOFFICIAL** tool
>
> It is **not affiliated with, endorsed by, sponsored by, or connected to LinkedIn Corporation** in any way. "LinkedIn" is a trademark of LinkedIn Corporation, used here solely to describe what this tool reads. This Actor is not a LinkedIn product and provides no LinkedIn-backed guarantee of accuracy, availability, or continuity.

Find **public LinkedIn posts that mention your keywords** and export them as structured JSON, CSV or Excel — post text, author, exact publication time, reaction and comment counts, media, and the comments themselves.

Give it a keyword, optionally a list of authors, and get back a clean dataset of matching posts.

Runs on Apify as **[`renovative_basilisk/linkedin-posts-scraper`](https://apify.com/renovative_basilisk/linkedin-posts-scraper)** — that is the Actor ID to pass to the API and the CLI.

***

### What it does

- Searches for **public LinkedIn posts** by keyword, with LinkedIn's Boolean syntax (`AND`, `OR`, `NOT`, `"quoted phrases"`, parentheses).
- Reads the **public page of any author you name** — a member profile or a company page — for their recent posts.
- Evaluates your query against **every post's actual text**, so a returned post really does mention what you asked for.
- Recovers each post's **exact publication timestamp**, and filters on recency *before* fetching anything.
- Optionally collects the **comments** LinkedIn shows to a signed-out visitor, with author, timestamp and per-comment like count.

### What it does **not** do

This matters, so it is stated plainly:

| | |
|---|---|
| ❌ **No login.** | It never signs in, never asks for your LinkedIn credentials, and never accepts session cookies or an `li_at` token. |
| ❌ **No private data.** | It reads only what LinkedIn serves to an anonymous visitor. |
| ❌ **No contact details.** | It does not collect emails, phone numbers or connection lists. |
| ❌ **No CAPTCHA solving, no protection defeated.** | It solves no challenges and reverse-engineers no anti-bot system. If a server throttles, it backs off and retries on a rotated proxy session. |

Post authors and commenters are **named individuals**, so the output contains personal data. See [Legal and compliance](#legal-and-compliance).

***

### How keyword search works — read this first

LinkedIn's own post search (`/search/results/content/`) is **behind the login wall**: signed out, it redirects to a sign-in page. There is no public, LinkedIn-native way to search posts by keyword. This Actor does not have a hidden way around that, and neither does anything else that does not log in.

So discovery uses two routes, and their results are merged and de-duplicated:

| Route | How it works | Reliability |
|---|---|---|
| **Author pages** | The public `/in/{identifier}` or `/company/{identifier}` page of an author you name lists their recent posts. | **High.** LinkedIn-native, no third party involved. Covers roughly the author's last 10–25 posts. |
| **Direct URLs** | Any post URL in `targetUrls` is scraped as given. | **Exact.** |
| **Web search** | DuckDuckGo, Yahoo and Bing, restricted to `site:linkedin.com/posts` and to your `postedLimit` window. Google and Mojeek are selectable but off by default: Google serves a JavaScript-only page to anything that is not a full browser, and Mojeek refuses proxied traffic, so both currently return nothing. | **Variable.** Depends on what each engine has indexed and on whether it serves your IP a challenge page instead of results. A challenge is recognised and retried through a fresh proxy session, but the fix is residential proxies. |

**Practical consequence:** if you know whose posts you want, fill in `authorPublicIdentifiers` or `authorsCompanyPublicIdentifiers`. That path does not depend on a search engine and is the one to rely on. An open keyword search across all of LinkedIn works, but its coverage is whatever the engines have indexed — it is not a complete view of LinkedIn, and no tool that stays signed out can offer one.

The keyword query is applied to every post either way, so a run mixing both routes still returns only posts that match.

***

### Input

The minimum viable input is a single search query:

```json
{
  "searchQueries": ["artificial intelligence"]
}
```

A fuller example — everything Bill Gates and Microsoft published about climate or AI in the past month, newest first, with comments:

```json
{
  "searchQueries": ["\"artificial intelligence\" OR climate"],
  "authorPublicIdentifiers": ["williamhgates"],
  "authorsCompanyPublicIdentifiers": ["microsoft"],
  "postedLimit": "month",
  "sortBy": "date",
  "maxPosts": 100,
  "scrapeComments": true,
  "maxComments": 10,
  "proxyConfiguration": {
    "useApifyProxy": true,
    "apifyProxyGroups": ["RESIDENTIAL"]
  }
}
```

#### Input reference

| Field | Type | Default | Description |
|---|---|---|---|
| `searchQueries` **(required)** | array of strings | — | Keyword phrases, **up to 10**. Supports `AND`, `OR`, `NOT`, `"quoted phrases"` and parentheses. Matching ignores case and accents, so `hopital americain` finds `Hôpital Américain`. Each is matched against every post; a post is kept if it matches any of them, and the matching query is reported as `searchQuery`. LinkedIn caps a query at 85 characters. |
| `authorPublicIdentifiers` | array of strings | — | Member public identifiers — `williamhgates` from `linkedin.com/in/williamhgates`. Their public page is read directly. Up to 50. |
| `authorsCompanyPublicIdentifiers` | array of strings | — | Company public identifiers — `microsoft` from `linkedin.com/company/microsoft`. The company that *published* the post. Up to 50. |
| `targetUrls` | array of strings | — | Profile, company or individual post URLs. Profile/company URLs are crawled for recent posts; a post URL is scraped directly. Localised hosts (`uk.linkedin.com`) are accepted. |
| `postedLimit` | enum | `any` | `any`, `1h`, `24h`, `week`, `month`, `3months`, `6months`, `year`. Applied before fetching, so a narrow window makes a run cheaper as well as smaller. The window is also passed to each engine's own date filter, so discovery returns recent posts rather than the engine's all-time favourites — which, for a niche topic, were all older than the window and left nothing to fetch. |
| `postedLimitDate` | string | — | Keep posts from this moment onwards. `2026-01-31`, `2026-01-31T14:48:00.000+09:00`, or epoch milliseconds. Combined with `postedLimit`, the stricter wins. |
| `sortBy` | enum | `relevance` | `relevance` streams results in discovery order. `date` writes the dataset strictly newest-first — and, because the ordering is decided before anything is fetched, a capped run also spends its budget on the newest posts rather than on whatever a search engine ranked first. |
| `scrapeComments` | boolean | `false` | Include the comments on each post's public page. The total count is reported either way. |
| `maxComments` | integer | `10` | Comments kept per post. LinkedIn shows roughly ten to a signed-out visitor, so higher values have no effect. |
| `maxPosts` | integer | `100` | Stop after this many unique posts. The main dial on run cost: it caps returned posts, search pages read, and posts checked and rejected — a run fetches at most five post pages per post you asked for. `0` is accepted and means "no limit", which here resolves to **1,000** posts — deliberately not the 10,000 maximum, because nobody typing `0` has costed the run out. That still authorises up to 5,000 charged fetches, so set a real number unless you mean it. The highest value you can type is 10,000. Discovery is capped separately and never exceeds 200 pages whatever `maxPosts` is — see [Pricing](#pricing). |
| `scrapePages` | integer | `2` | Pages of search-engine results per query. Each yields up to ~30 post links. |
| `startPage` | integer | `1` | First search-engine result page. Use it to continue a previous run. |
| `searchEngines` | array of enums | `duckduckgo`, `yahoo`, `bing` | Also accepts `google` and `mojeek`, both off by default: Google serves a JavaScript-only page to non-browser clients and Mojeek refuses proxied traffic, so their pages cost a fetch and return nothing. Yahoo serves Bing's index in a form that works. **Clear the list to disable web search entirely** and scrape only the authors and URLs you supplied. |
| `maxConcurrency` | integer | `5` | Requests in flight, **capped at 10**. Raising it mostly increases `429`s and the retries they cause, not throughput. |
| `proxyConfiguration` | object | Apify Proxy, `RESIDENTIAL` | Strongly recommended — see [Proxies](#proxies). |
| `respectRobotsTxt` | boolean | `false` | Obey `robots.txt`. **Enabling this returns zero results** — see [Legal and compliance](#legal-and-compliance). |

***

### Output

One dataset item per post. Real output from a live run, truncated for readability:

```json
{
  "type": "post",
  "id": "7489894675928027136",
  "urn": "urn:li:activity:7489894675928027136",
  "attributedUrn": "urn:li:share:7489894675324166145",
  "linkedinUrl": "https://www.linkedin.com/posts/williamhgates_heineken-is-trying-a-heat-battery-to-generate-activity-7489894675928027136-g8C-",
  "content": "I'll raise a glass for lower emissions.",
  "author": {
    "name": "Bill Gates",
    "type": "profile",
    "publicIdentifier": "williamhgates",
    "linkedinUrl": "https://www.linkedin.com/in/williamhgates",
    "avatar": "https://media.licdn.com/dms/image/v2/D5603AQF-RYZP55jmXA/...",
    "followers": 40566873,
    "info": null
  },
  "postedAt": {
    "timestamp": 1785730046255,
    "date": "2026-08-03T04:07:26.255Z",
    "postedAgoText": "3d"
  },
  "postImages": [
    { "url": "https://media.licdn.com/dms/image/sync/v2/...", "alt": "Heineken Is Trying a Heat Battery..." }
  ],
  "linkedArticle": {
    "url": "https://www.wsj.com/pro/sustainable-business/heineken-is-trying-a-heat-battery...",
    "title": "Heineken Is Trying a Heat Battery to Generate Steam for Its Brewing",
    "subtitle": "wsj.com"
  },
  "engagement": {
    "likes": 1152,
    "comments": 159,
    "shares": null,
    "reactionTypes": ["LIKE", "PRAISE", "INTEREST"]
  },
  "comments": [
    {
      "text": "I like that this example makes climate innovation tangible...",
      "createdAt": "2026-08-03T04:25:04.052Z",
      "postedAgoText": "3d",
      "likes": 7,
      "author": {
        "name": "Justin W.",
        "linkedinUrl": "https://www.linkedin.com/in/justin-w-91133667",
        "publicIdentifier": "justin-w-91133667",
        "avatar": "https://static.licdn.com/aero-v1/sc/h/..."
      }
    }
  ],
  "searchQuery": "\"artificial intelligence\" OR climate",
  "discoveredVia": "author-page"
}
```

#### Output reference

| Field | Type | Notes |
|---|---|---|
| `type` | string | Always `post`. |
| `id` | string | LinkedIn's numeric activity ID. Stable, and the de-duplication key. |
| `urn` | string | `urn:li:activity:{id}`. |
| `attributedUrn` | string | null | The post's `ugcPost`/`share` URN, which is a **different number** from the activity ID. Both are reported because conflating them produces URLs that resolve to nothing. |
| `linkedinUrl` | string | The public permalink. For a post discovered without one, the canonical `/feed/update/…` URL — LinkedIn 404s a permalink whose slug and hash were not the original ones, so it is never synthesised. |
| `content` | string | null | Post body, with the author's line breaks preserved. |
| `author.name` | string | null | Member or company name. |
| `author.type` | string | null | `profile` or `company`. |
| `author.publicIdentifier` | string | null | e.g. `williamhgates`, `microsoft`. |
| `author.linkedinUrl` | string | null | Author page, tracking parameters stripped. |
| `author.avatar` | string | null | Profile picture or company logo. |
| `author.followers` | integer | null | Follower count as rendered. |
| `author.info` | string | null | The line under the author's name — a member's headline, or a company's follower line. |
| `postedAt.date` | string | ISO-8601 UTC with milliseconds. |
| `postedAt.timestamp` | integer | Epoch milliseconds. |
| `postedAt.postedAgoText` | string | null | LinkedIn's relative phrasing, e.g. `3d`. |
| `postImages` | array | `{url, alt}` for each image in the post. Avatars and reaction glyphs are excluded. |
| `linkedArticle` | object | null | `{url, title, subtitle}` for a linked article or external page. LinkedIn's `/redir/redirect` wrapper is resolved to the real destination. |
| `engagement.likes` | integer | null | Total reactions. |
| `engagement.comments` | integer | null | Total comments — LinkedIn's own figure, not the number returned in `comments`. |
| `engagement.shares` | null | **Always `null`.** LinkedIn does not render a repost count to signed-out visitors; it is reported as unknown rather than guessed. |
| `engagement.reactionTypes` | array | Which reaction types are displayed, e.g. `["LIKE","EMPATHY","APPRECIATION"]`. Per-type counts are not public. |
| `comments` | array | Empty unless `scrapeComments` is on. Each: `{text, createdAt, postedAgoText, likes, author:{name, linkedinUrl, publicIdentifier, avatar}}`. |
| `searchQuery` | string | Which of your queries this post matched. |
| `discoveredVia` | string | `author-page`, `input-url`, or `search:{engine}`. |

Fields are always present; unavailable values are `null` rather than omitted, so the dataset has a stable shape for CSV and Excel export.

***

### Pricing

This Actor uses **pay per event**. You are charged for the work a run does on your behalf — the posts it returns, and the posts it had to fetch and read to find them:

| Event | Price | When it fires |
|---|---|---|
| `actor-start` | **$0.005** | Once per run. Covers the discovery phase — the search-engine result pages that find the posts, which are fetched before any post is. |
| `post-scraped` | **$0.00175** — $1.75 per 1,000 posts | Per post saved without comments. |
| `post-scraped-with-comments` | **$0.003** — $3.00 per 1,000 posts | Per post that actually carried at least one comment. |
| `post-filtered` | **$0.001** — $1.00 per 1,000 posts | Per post fetched but **not** returned: its text did not match your query, LinkedIn served a sign-in page instead of the post, or the fetch was blocked and gave up after its retries. Charged once per post, whatever the number of attempts. |

**Why a checked-but-unreturned post is charged.** Fetching a post through a residential proxy is the expensive part of a run, and it costs the same whether or not the post turns out to match your query — the text can only be checked after the page has been fetched. A run whose keyword matches nothing is therefore the *most* expensive kind to serve, not the cheapest, because every rejected post frees its slot and pulls another candidate in behind it. Charging for the check at well under half the price of a delivered post is what keeps that from being free, and it is why this Actor can afford to price delivered posts below the market.

**How to pay less of it.** The ratio of checked to returned posts is entirely under your control: name authors in `authorPublicIdentifiers` or `authorsCompanyPublicIdentifiers` and use a query that matches how those authors actually write. A broad keyword across all of LinkedIn examines many posts per result; a named author with a fitting query examines barely more than it returns. A run reads at most **five times `maxPosts` posts in total**, delivered and rejected together — so `post-filtered` can add at most 5 × `maxPosts` × $0.001. The run stops there and logs why. (At `maxPosts: 0` that multiplier applies to the 1,000 posts `0` resolves to — 5,000 fetches, up to $7.005 — which is why `0` is not a setting to leave on by accident.) Each post is charged once however many times we have to retry it against a throttling LinkedIn; those retries are our cost, not yours.

**How that compares.** Against the most-used LinkedIn post-search Actor on this Store, which charges $0.002 per post, the list price here is **12.5% lower** — but compare honestly: that Actor does not bill for posts it examines and discards, so on a broad keyword your *effective* cost per delivered post here can exceed its flat rate. On the "one post in five" row above it works out at $0.0058 per post kept. Where this Actor wins is a run that knows what it is looking for; where it loses is a wide trawl. The larger difference is comments: that Actor bills **each comment as its own $0.002 event**, so one post with ten comments costs $0.022 there. Comments arrive in the same request as the post here — they cost no extra fetch — so a post *with* all its comments is **$0.003 flat**, roughly **seven times cheaper**. That Actor also bills $0.001 for a query that returns nothing at all, on the same reasoning as `post-filtered` here: a search that finds nothing still costs the machine that ran it.

Posts outside `postedLimit` are **not** charged at all: their publication time is read from the activity ID before anything is fetched, so an out-of-window post costs nothing. A post that is fetched and then rejected does not consume your `maxPosts` either — it releases its slot, so the run still works toward the number of posts you asked for — but it is charged at the `post-filtered` rate, because the fetch already happened. (One exception, stated for completeness: if writing a post to the dataset fails partway, that post's slot is treated as spent rather than reused, because the row may already exist and reusing it could deliver one more post than you capped. Such a run returns one fewer post, never one more.)

Setting `maxPosts` caps your spend, and it caps discovery and checking as well as results: the number of posts read scales with `maxPosts` (at five times it), and the number of search-result pages scales with it too — half of `maxPosts`, never fewer than 20 and **never more than 200**, whatever `maxPosts` says. So a run cannot quietly spend more on looking for posts than it can return. Each of those 5 × `maxPosts` fetches is charged once and once only, at one rate or the other, so the arithmetic ceiling on a run is **$0.005 + $0.007 × `maxPosts`** — $0.705 at the default, reached only by a run that delivers every post it is allowed to *and* rejects four times as many *and* finds comments on all of them. Worked figures at the default `maxPosts: 100`, where the run reads at most 500 posts:

| What the query matches | Delivered | Read but rejected | You pay |
|---|---|---|---|
| Everything it reads | 100 | 0 | **$0.18** |
| One post in five | 100 | 400 | **$0.58** |
| One in five, `scrapeComments: true` | 100 | 400 | **$0.705** |
| Nothing at all | 0 | 500 | **$0.505** |

Naming authors and matching how they actually write is what moves you up that table. The dearest row is not the one that fails — it is the one that trawls widely and succeeds. Each row is the arithmetic for that split, not a promise that the run will get that far: a run stops as soon as any of its limits binds, and you are charged only for what it actually did.

#### When a run stops early

Fetching a page through a residential proxy costs this Actor real money before it earns anything, and discovery — the search-engine and author pages that find the posts — earns nothing at all. Two separate mechanisms stop a run whose spending has outrun what it can charge for. They are not the same thing and they do not look the same to you.

**1. Refused before it starts — the run FAILS and nothing is crawled or charged.** Two checks run ahead of the `actor-start` charge:

- The Actor's pay-per-event pricing is missing or incomplete on the platform. That is a publisher-side misconfiguration; the run fails rather than delivering paid work for nothing.
- Your `maxTotalChargeUsd` is below what the run's own discovery phase costs. Discovery is fetched before a single post can be charged for, so a cap under that price buys a crawl that can never pay for itself, and a fast failure is the honest answer. The threshold depends on how many discovery pages *your* input asks for — roughly **$0.0086 plus $0.00036 per discovery page**:

  | Run | Discovery pages | Cap must be at least |
  |---|---|---|
  | Default: one query, four engines, two pages each | 8 | **$0.0115** |
  | Saturating discovery at `maxPosts: 100` | 50 | **$0.0266** |
  | Any run at the 200-page discovery ceiling | 200 | **$0.0805** |

  The failure message names the exact figure for your run. Note that the **$0.01 platform minimum is below the threshold for almost every run**, so it is not a usable cap for real work. **$0.10 clears any run this Actor can construct**, since discovery can never exceed 200 pages.

**2. Stopped part-way — the run ends normally and keeps what it collected.** While the run is going it compares what it has been charged for against what its proxied fetches have cost, and stops when the fetches it is still allowed to make could not close the gap. Concretely:

- It waits before judging anything: **40 discovery attempts** (while no post has been fetched yet) or **10 post-page attempts**. Every run is legitimately behind when discovery ends, because discovery earns nothing.
- **It never stops a run that is ahead.** If what the run has been charged already covers what it has cost, it continues, whatever the projection says about the fetches to come.
- **A discovery still in progress is judged on the discovery it is buying, not on the part that has come back.** At attempt 40 a 50-page discovery has ten pages queued and already paid for; the pages still to come are credited with the yield this run is measuring, so a crawl finding one new post link per page is judged on the fifty it is on course to find rather than the thirty it has counted. A crawl finding none is still credited with none — that case is the one this exists to catch.
- What it counts as still earnable is bounded by whichever is smallest: the posts left under your `maxPosts`, what is left of the 5 × `maxPosts` fetch ceiling, and the candidates discovery has found or is on course to find. Each is valued at **what this run is actually earning per fetch** — the mix of `post-scraped`, `post-scraped-with-comments` and `post-filtered` it has measured — not at the cheapest outcome it might have had. Before any post has been fetched, a candidate is valued at the `post-scraped` price; while fetches are coming back with nothing in them, at the `post-filtered` floor.
- A fetch only earns more than it costs while it is not being retried much, and how much is too much depends on what the run earns: a run delivering posts at $0.00175 stops covering its fetches at about **2.4 proxied attempts each**, one billing everything at the `post-filtered` floor at about **1.4**. Past that no number of remaining fetches can repay anything — which is what a blocked run looks like from the inside.

**What you see.** The run finishes in its normal successful terminal state. The terminal status message says it was stopped early, quotes the estimated platform cost against the net revenue, and names **what was actually observed** — no candidates found, post pages fetched but never returning, fetches being retried, posts fetched but rejected by the keyword filter, or discovery simply costing more than the posts it found can repay. The same explanation, with the full numbers, is in the log. The dataset holds everything collected up to that point; it does not quietly return less and call it a complete run. You are billed for the events already charged and nothing more.

**What to change** depends on which of those it says:

| What the message says | What to change |
|---|---|
| Discovery found no post links at all | Enable Apify Proxy on `RESIDENTIAL` — search engines serve challenge pages to datacenter IPs — or name authors so discovery does not depend on a search engine at all. |
| Discovery cost more than the candidates it found can repay | Narrow it: fewer `searchQueries`, fewer `searchEngines`, a smaller `scrapePages`. Raising `maxPosts` is the one thing that cannot help here — the fetches the run has left are bounded by the candidates discovery found, not by `maxPosts`, and a larger `maxPosts` buys more of the search pages that caused the stop. |
| Post pages are being fetched and none come back | LinkedIn is throttling or blocking. Residential proxy, and lower `maxConcurrency`. |
| Fetches are being retried — *N* proxied attempts per fetch, against 1.0 on a healthy run | Same: residential proxy, lower `maxConcurrency`. |
| Pages were fetched but few produced a post | The keyword filter is rejecting them. Broaden or correct `searchQueries`, and narrow discovery with `authorPublicIdentifiers` so fewer irrelevant posts are fetched at all. |
| Posts are delivering, but discovery cost more than they earn | Narrow discovery, or raise `maxPosts` so one discovery phase is spread over more paid posts. |

**What this is not.** It is not a guarantee that a run never costs more than it returns, and it is not a refund mechanism. It stops the spending at the point it notices: requests already in flight still finish and are still charged, so the run can overshoot by up to `maxConcurrency` fetches, while everything merely queued behind them is never fetched. It is off entirely for runs that are not charging at all, such as a local run.

***

### Proxies

Search engines serve challenge pages to datacenter and shared IPs, and LinkedIn rate-limits anonymous traffic aggressively.

- **Recommended:** Apify Proxy with the `RESIDENTIAL` group (the default).
- **Workable for author-only runs:** Apify Proxy datacenter — LinkedIn's public author pages tolerate it better than the search engines do.
- **Not recommended:** no proxy. Keyword discovery will find little or nothing; the Actor logs a warning saying so.

`429`, `401` and `403` responses are treated as blocks, so the request is retried on a rotated proxy session rather than parsed as an empty result. Those rotations are bounded at **one** per request — so a blocked request costs **at most two proxied attempts in all**: the original, and one retry on a fresh proxy session. Past that the candidate is given up on. Measured across real runs, a request that failed once and succeeded on its second try is common, while nothing was ever recovered by a third through ninth attempt: the requests that went past two attempts went all the way to Crawlee's default of ten and still failed. (A failure that is *not* a block — a timeout, say — is retried up to three times instead, so four attempts.) Those attempts are our cost, not yours — the post is charged once, as `post-filtered`.

***

### Performance and cost

| Setting | Requests per post | Notes |
|---|---|---|
| Author pages only (`searchEngines: []`) | ~1.1 | One page per author, then one per post. Fastest and most reliable. |
| Web search + authors (default) | ~1.3 | Adds one request per query × engine × page. |
| `scrapeComments: true` | unchanged | Comments come from the same page as the post — they cost no extra request. |

Rough guide: 100 posts takes a few minutes at the default concurrency of 5. Raising `maxConcurrency` usually produces more retries rather than more throughput, which is why it is capped at 10.

**Discovery is capped, in proportion to `maxPosts` and absolutely.** Search fans out as queries × authors × engines × pages, and that product can dwarf what a run can return — so the number of discovery requests is limited to half of `maxPosts`, never fewer than **20** and **never more than 200**. That second cap binds from `maxPosts: 400` upwards and does not lift however large `maxPosts` is, because discovery earns nothing: 200 uncharged pages is already about $0.05 of proxy spend before a single post is fetched, and it takes over 250 charged post fetches to earn that back. Author pages are filled from that budget first — they are LinkedIn-native and do not depend on a search engine — and whatever they leave goes to keyword search. When the cap bites, the run logs how many pages it dropped, and the requests it keeps are spread evenly across every dimension rather than being spent entirely on the first of each: author scope first, then engine, then query. One detail worth knowing: keyword search is narrowed to individual authors only while you have named **10 or fewer** of them. Past that the cross-product would dominate the run, so search runs unscoped and those authors are covered by their own public pages instead. Raise `maxPosts` to widen discovery up to the 200-page ceiling; narrow `searchQueries`, `scrapePages` or the author list to spend the same budget more deeply.

***

### Usage from code

```python
from apify_client import ApifyClient

client = ApifyClient('YOUR_APIFY_TOKEN')

run = client.actor('renovative_basilisk/linkedin-posts-scraper').call(
    run_input={
        'searchQueries': ['"artificial intelligence" OR climate'],
        'authorPublicIdentifiers': ['williamhgates'],
        'postedLimit': 'month',
        'sortBy': 'date',
        'maxPosts': 50,
    }
)

for post in client.dataset(run['defaultDatasetId']).iterate_items():
    print(post['postedAt']['date'], post['author']['name'], '—', post['engagement']['likes'])
```

***

### Legal and compliance

**Read this before running the Actor.** It is stated in full rather than summarised because the honest position is more complicated than "it only reads public data".

#### robots.txt

`https://www.linkedin.com/robots.txt` sends `Disallow: /` to all non-whitelisted user agents, and explicitly disallows `/feed/update/` and `/embed/feed/update/` even for the search engines it names.

robots.txt is a crawler-directive convention, not a contract or a statute, and its legal weight varies by jurisdiction. What it unambiguously is: a clear signal about the access LinkedIn intends to permit. You should factor that into your decision.

There is no configuration of this Actor that both respects robots.txt and returns results. The `respectRobotsTxt` input is exposed so that:

- the behaviour is verifiable rather than hidden;
- enabling it demonstrably returns zero items, which documents the conflict;
- the decision to proceed is a deliberate, recorded choice by the operator.

The default is `false`, because `true` makes the Actor non-functional. That default is a statement about what the Actor does, not a claim that it is permitted.

#### Terms of service

Automated collection of LinkedIn data is contrary to the [LinkedIn User Agreement](https://www.linkedin.com/legal/user-agreement). Scraping public data has been treated as lawful in some jurisdictions (notably *hiQ Labs v. LinkedIn* in the US Ninth Circuit, on CFAA grounds), but that ruling did not make it contract-compliant, does not bind other jurisdictions, and does not settle claims LinkedIn may bring in contract or under other statutes.

The web search engines used for keyword discovery have their own terms, which also address automated querying. The same reasoning applies.

#### Data protection

**This Actor collects personal data.** Post authors and commenters are identified individuals: name, public profile URL, profile picture, headline and follower count, plus whatever they wrote. Under GDPR and CCPA that is personal data regardless of the fact that they published it themselves.

If you process these records for EU or California data subjects you need a lawful basis, you must be able to honour access and deletion requests, and "it was public" is not by itself a lawful basis. Turning `scrapeComments` off substantially reduces the number of individuals a run touches — comments bring in people who commented on a post, not just the author.

This Actor deliberately collects no contact details and does not touch profile pages beyond the post list they publish.

#### Your responsibility

Apify's [Store Publishing Terms](https://docs.apify.com/legal/store-publishing-terms-and-conditions) and [Actor Terms](https://docs.apify.com/legal/actor-terms-and-conditions) place compliance with third-party terms and applicable law on the party running the Actor. **You are responsible for determining whether your use is lawful in your jurisdiction and for your purpose.** If you need certainty, LinkedIn's official [Marketing and Talent APIs](https://developer.linkedin.com/) are the sanctioned route.

***

### Limitations

- **Keyword-only coverage is not complete.** Without a login there is no LinkedIn-native post search, so an unscoped keyword run sees only what web search engines have indexed. Name the authors you care about to get reliable coverage of them. See [How keyword search works](#how-keyword-search-works--read-this-first).
- **Author pages show recent posts only.** LinkedIn's signed-out rendering lists roughly the last 10–25 posts per author, and offers no pagination to a visitor. Older posts are reachable only through search-engine discovery.
- **An author page also lists what that author reshared.** Those posts are returned under **their original author**, not the person whose page they were found on — the same convention as comparable Actors, and the reason `author.name` in your dataset will not always be the identifier you asked for. Filter on `author.publicIdentifier` if you need strictly first-party posts.
- **`engagement.shares` is always `null`.** Repost counts are not rendered to signed-out visitors.
- **Reaction identities are not available.** You get totals and which reaction types appear, not who reacted. Retrieving that requires a logged-in session, which this Actor does not use.
- **Comments are capped by LinkedIn, not by this Actor.** A post with 400 comments shows about ten publicly; `engagement.comments` reports the true total.
- **Reposts are collapsed onto the original.** A reshare and the post it shares have different activity IDs but one permalink; the original is what you get.
- **`author.info` varies by author type.** For a member it is their headline; for a company it is the follower line.
- **Search-engine ranking is not reproducible.** `relevance` ordering is the engines' and differs run to run. Use `sortBy: date` for anything you need to be deterministic.
- **Markup changes break parsers.** LinkedIn changes its HTML without notice. Parsing is isolated in `src/parsers.py` and covered by fixture-based tests so fixes are quick — please report breakage via the Issues tab.

***

### Troubleshooting

**Zero results, and the log says search engines found nothing.**
Most often the engines served challenge pages instead of results. DuckDuckGo's challenge (served as HTTP 202), Google's "unusual traffic" page and Mojeek's refusal are recognised and retried through a fresh proxy session, so a run that still ends this way has used up its rotations: enable Apify Proxy with `RESIDENTIAL`. If the log instead says posts were *discovered* but fell outside the time window, the engines' date filters found only older posts for that query: widen `postedLimit`, raise `scrapePages`, or name authors. To take search engines out of the picture entirely, set `searchEngines: []` and name authors instead.

**Zero results with `respectRobotsTxt: true`.**
That is the designed behaviour — see [Legal and compliance](#legal-and-compliance).

**Posts were discovered but few were saved.**
They did not match your query. The terminal status message reports how many were discarded by the keyword filter. Boolean queries are strict: `hiring AND engineer` needs both words, and terms match whole words only (`engine` does not match `engineering`). Accents and curly quotes are *not* a cause: matching folds both sides, so `hopital americain` finds a post that says “l’Hôpital Américain”, and either spelling finds the other.

**"No posts found on the public page" for an author.**
Check the identifier is the one in their URL (`linkedin.com/in/**williamhgates**`). LinkedIn also hides the post section from throttled requests — enable a proxy and retry.

**Fewer items than `maxPosts`.**
Usually discovery ran out of candidates: add authors, raise `scrapePages`, widen `postedLimit`, or loosen the query. If the run stopped for another reason it says so — see the next entry.

**The run ended early, or returned less than I asked for.**
Read the run's **terminal status message** (top of the run page, also the last line of the log): a run that stops before its limits always names the reason there. The reasons are:

- **"the most a `maxPosts` of N allows"** — the run hit its ceiling of 5 × `maxPosts` fetched post pages while most of them were being rejected by the keyword filter. Broaden `searchQueries`, or narrow discovery with `authorPublicIdentifiers` so fewer irrelevant posts get fetched. See [Pricing](#pricing).
- **A charge limit was reached.** You set `maxTotalChargeUsd` (or your account hit its own limit) and the run stopped rather than doing work it cannot bill for. Raise the limit or lower `maxPosts`.
- **The run would have cost more to produce than it charges.** The status message names which of several causes was actually observed — no candidates found, fetches being blocked, or the keyword filter rejecting nearly everything — and each has a different fix. See [When a run stops early](#when-a-run-stops-early).
- **Discovery was capped.** The log says how many search pages were dropped. Raise `maxPosts` (up to the point the 200-page ceiling binds), or narrow `searchQueries`/`scrapePages`/the author list. See [Performance and cost](#performance-and-cost).

Whatever the reason, the dataset keeps everything collected up to that point, and you are charged only for the events already charged.

**The run failed saying pay-per-event pricing is incomplete or not in effect.**
That is deliberate and it is not your input: the Actor refuses to scrape when the platform is not applying its per-event prices, because it would otherwise do the whole run and charge nothing. Report it via the Issues tab — nothing you can change in the input will fix it.

**Comments are empty.**
`scrapeComments` is `false`. Turn it on.

***

### Local development

```bash
pip install -r requirements.txt
pip install pytest pytest-asyncio ruff
python -m pytest        # runs offline against saved fixtures
ruff check .
```

Run the Actor locally with the [Apify CLI](https://docs.apify.com/cli):

```bash
apify run --input='{"searchQueries":["climate"],"authorPublicIdentifiers":["williamhgates"],"searchEngines":[],"maxPosts":5}'
```

Tests never hit the network: `tests/fixtures/` holds real captured LinkedIn responses, so parser regressions are caught without generating traffic.

### Changelog

See [CHANGELOG.md](CHANGELOG.md).

# Actor input Schema

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

Keyword phrases to search for, one per line. Supports LinkedIn's Boolean syntax — `AND`, `OR`, `NOT`, "quoted phrases" and parentheses — for example `("machine learning" OR "deep learning") NOT hiring`. Every query is evaluated against each post's text, so results really do mention what you asked for. LinkedIn caps a query at 85 characters. Up to 10 queries per run — every query is searched across every author you list and every engine, so the number of search requests grows with the product of the three. The run caps that total in proportion to "Maximum posts" — half of it, never fewer than 20 — and never fetches more than 200 discovery pages however large "Maximum posts" is.

## `authorPublicIdentifiers` (type: `array`):

Public identifiers of members whose posts you want — `williamhgates` from `https://www.linkedin.com/in/williamhgates`. Their public profile page is read directly, which does not depend on a search engine, and the keyword query is still applied.

## `authorsCompanyPublicIdentifiers` (type: `array`):

Public identifiers of company pages whose posts you want — `microsoft` from `https://www.linkedin.com/company/microsoft`. Note: this is the company that *published* the post, not the employer of a member who posted.

## `targetUrls` (type: `array`):

Profile, company or individual post URLs. Profile and company URLs are crawled for their recent posts; a post URL is scraped directly. Accepts localised hosts such as `uk.linkedin.com`.

## `postedLimit` (type: `string`):

Only keep posts published within this window. Applied from each post's own publication time, before the post is fetched — so a narrow window makes a run cheaper as well as smaller. The window is also passed to each search engine's own date filter, so discovery returns recent posts rather than the engine's all-time favourites.

## `postedLimitDate` (type: `string`):

Keep posts published from this moment up to now. Accepts a date (`2026-01-31`), a full timestamp (`2026-01-31T14:48:00.000+09:00`) or epoch milliseconds (`1769870880000`). If you also set "Posted within", the stricter of the two applies.

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

`Relevance` keeps the order posts were discovered in. `Newest first` orders the dataset chronologically, which is what you want for monitoring runs — and, because the order is decided before anything is fetched, it also means a capped run spends its budget on the newest posts.

## `scrapeComments` (type: `boolean`):

Include the comments LinkedIn renders on a post's public page — text, author, exact timestamp and per-comment like count. The total comment count is reported either way.

## `maxComments` (type: `integer`):

Cap on comments kept per post. LinkedIn shows roughly ten to a signed-out visitor, so values above that have no effect.

## `maxPosts` (type: `integer`):

Stop after this many unique posts across all queries. This is the main dial on what a run costs: it caps the posts returned, the search-result pages read while looking for them, and the posts checked and rejected along the way — a run fetches at most five post pages per post you asked for, and each fetch is charged whether or not the post matches. 0 is accepted and still means "no limit", but there is no such thing: it resolves to 1,000 posts, which still authorises up to 5,000 charged post fetches — a bill of several dollars. It is deliberately not the 10,000 maximum, because nobody typing 0 has costed the run out; type 10,000 if you really mean it, and having typed it you have accepted the bill. If a run would cost more to produce than it charges, it stops early and says so rather than running to this limit.

## `scrapePages` (type: `integer`):

How many pages of search-engine results to read for each query. Each page yields up to about 30 post links. Raising this widens discovery at the cost of more requests and a higher chance of being rate-limited.

## `startPage` (type: `integer`):

First page of search-engine results to read. Use it to continue where a previous run stopped.

## `searchEngines` (type: `array`):

LinkedIn's own post search requires a login, so keyword discovery goes through web search engines instead. Results from all selected engines are merged and de-duplicated; none of them is reliable alone, which is why several are enabled by default. Each engine is also asked for your "Posted within" window, so its pages hold posts the run can use. Google and Mojeek are not enabled by default: Google serves a JavaScript-only page to any client that is not a full browser, and Mojeek refuses proxied traffic outright, so their pages currently cost a fetch and return nothing. Yahoo serves Bing's index in a form that works. Clear this list to disable keyword discovery entirely and scrape only the authors and URLs you supplied.

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

How many requests may be in flight at once. LinkedIn and the search engines both throttle aggressively; raising this increases the rate of `429` responses and retries rather than the throughput — and a throttled request is retried on a fresh proxy session, so the extra concurrency multiplies the retries rather than the results. That is why the ceiling here is 10 and not higher: past that the run reliably spends more on being blocked than it gains in speed. Leave at 5 unless you are on residential proxies and know the run is being limited by concurrency.

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

Strongly recommended. Search engines serve challenge pages to datacenter and shared IPs, and LinkedIn blocks them quickly; use Apify Proxy with the RESIDENTIAL group for reliable runs.

## `respectRobotsTxt` (type: `boolean`):

When enabled, the crawler obeys the robots.txt of every host it visits. Note that linkedin.com/robots.txt sends `Disallow: /` to all non-whitelisted user agents, so enabling this will produce ZERO results. It is exposed so you can make that determination deliberately, and so a compliance reviewer can verify the behaviour. See the Legal and compliance section of the README before running this Actor.

## Actor input object example

```json
{
  "searchQueries": [
    "artificial intelligence",
    "\"generative ai\" AND healthcare"
  ],
  "authorPublicIdentifiers": [
    "williamhgates"
  ],
  "authorsCompanyPublicIdentifiers": [
    "microsoft",
    "google"
  ],
  "targetUrls": [
    "https://www.linkedin.com/company/microsoft",
    "https://www.linkedin.com/in/williamhgates"
  ],
  "postedLimit": "any",
  "postedLimitDate": "2026-01-31",
  "sortBy": "relevance",
  "scrapeComments": false,
  "maxComments": 10,
  "maxPosts": 100,
  "scrapePages": 2,
  "startPage": 1,
  "searchEngines": [
    "duckduckgo",
    "yahoo",
    "bing"
  ],
  "maxConcurrency": 5,
  "proxyConfiguration": {
    "useApifyProxy": true,
    "apifyProxyGroups": [
      "RESIDENTIAL"
    ]
  },
  "respectRobotsTxt": false
}
```

# Actor output Schema

## `posts` (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 = {
    "searchQueries": [
        "artificial intelligence"
    ],
    "proxyConfiguration": {
        "useApifyProxy": true,
        "apifyProxyGroups": [
            "RESIDENTIAL"
        ]
    }
};

// Run the Actor and wait for it to finish
const run = await client.actor("renovative_basilisk/linkedin-posts-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": ["artificial intelligence"],
    "proxyConfiguration": {
        "useApifyProxy": True,
        "apifyProxyGroups": ["RESIDENTIAL"],
    },
}

# Run the Actor and wait for it to finish
run = client.actor("renovative_basilisk/linkedin-posts-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": [
    "artificial intelligence"
  ],
  "proxyConfiguration": {
    "useApifyProxy": true,
    "apifyProxyGroups": [
      "RESIDENTIAL"
    ]
  }
}' |
apify call renovative_basilisk/linkedin-posts-scraper --silent --output-dataset

```

## MCP server setup

```json
{
    "mcpServers": {
        "apify": {
            "type": "http",
            "url": "https://mcp.apify.com/?tools=fetch-actor-details,renovative_basilisk/linkedin-posts-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/nJvHop2i1lXyXJkUP/builds/6AwaChnKpPDJDMtdm/openapi.json
