LinkedIn Posts Scraper avatar

LinkedIn Posts Scraper

Pricing

from $1.75 / 1,000 post scrapeds

Go to Apify Store
LinkedIn Posts Scraper

LinkedIn Posts Scraper

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.

Pricing

from $1.75 / 1,000 post scrapeds

Rating

5.0

(1)

Developer

coding laporte

coding laporte

Maintained by Community

Actor stats

0

Bookmarked

2

Total users

1

Monthly active users

2 days ago

Last modified

Share

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


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:

RouteHow it worksReliability
Author pagesThe 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 URLsAny post URL in targetUrls is scraped as given.Exact.
Web searchDuckDuckGo, 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:

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

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

{
"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

FieldTypeDefaultDescription
searchQueries (required)array of stringsKeyword 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.
authorPublicIdentifiersarray of stringsMember public identifiers — williamhgates from linkedin.com/in/williamhgates. Their public page is read directly. Up to 50.
authorsCompanyPublicIdentifiersarray of stringsCompany public identifiers — microsoft from linkedin.com/company/microsoft. The company that published the post. Up to 50.
targetUrlsarray of stringsProfile, 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.
postedLimitenumanyany, 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.
postedLimitDatestringKeep 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.
sortByenumrelevancerelevance 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.
scrapeCommentsbooleanfalseInclude the comments on each post's public page. The total count is reported either way.
maxCommentsinteger10Comments kept per post. LinkedIn shows roughly ten to a signed-out visitor, so higher values have no effect.
maxPostsinteger100Stop 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.
scrapePagesinteger2Pages of search-engine results per query. Each yields up to ~30 post links.
startPageinteger1First search-engine result page. Use it to continue a previous run.
searchEnginesarray of enumsduckduckgo, yahoo, bingAlso 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.
maxConcurrencyinteger5Requests in flight, capped at 10. Raising it mostly increases 429s and the retries they cause, not throughput.
proxyConfigurationobjectApify Proxy, RESIDENTIALStrongly recommended — see Proxies.
respectRobotsTxtbooleanfalseObey robots.txt. Enabling this returns zero results — see Legal and compliance.

Output

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

{
"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

FieldTypeNotes
typestringAlways post.
idstringLinkedIn's numeric activity ID. Stable, and the de-duplication key.
urnstringurn:li:activity:{id}.
attributedUrnstring | nullThe 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.
linkedinUrlstringThe 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.
contentstring | nullPost body, with the author's line breaks preserved.
author.namestring | nullMember or company name.
author.typestring | nullprofile or company.
author.publicIdentifierstring | nulle.g. williamhgates, microsoft.
author.linkedinUrlstring | nullAuthor page, tracking parameters stripped.
author.avatarstring | nullProfile picture or company logo.
author.followersinteger | nullFollower count as rendered.
author.infostring | nullThe line under the author's name — a member's headline, or a company's follower line.
postedAt.datestringISO-8601 UTC with milliseconds.
postedAt.timestampintegerEpoch milliseconds.
postedAt.postedAgoTextstring | nullLinkedIn's relative phrasing, e.g. 3d.
postImagesarray{url, alt} for each image in the post. Avatars and reaction glyphs are excluded.
linkedArticleobject | null{url, title, subtitle} for a linked article or external page. LinkedIn's /redir/redirect wrapper is resolved to the real destination.
engagement.likesinteger | nullTotal reactions.
engagement.commentsinteger | nullTotal comments — LinkedIn's own figure, not the number returned in comments.
engagement.sharesnullAlways null. LinkedIn does not render a repost count to signed-out visitors; it is reported as unknown rather than guessed.
engagement.reactionTypesarrayWhich reaction types are displayed, e.g. ["LIKE","EMPATHY","APPRECIATION"]. Per-type counts are not public.
commentsarrayEmpty unless scrapeComments is on. Each: {text, createdAt, postedAgoText, likes, author:{name, linkedinUrl, publicIdentifier, avatar}}.
searchQuerystringWhich of your queries this post matched.
discoveredViastringauthor-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:

EventPriceWhen it fires
actor-start$0.005Once 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 postsPer post saved without comments.
post-scraped-with-comments$0.003 — $3.00 per 1,000 postsPer post that actually carried at least one comment.
post-filtered$0.001 — $1.00 per 1,000 postsPer 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 matchesDeliveredRead but rejectedYou pay
Everything it reads1000$0.18
One post in five100400$0.58
One in five, scrapeComments: true100400$0.705
Nothing at all0500$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:

    RunDiscovery pagesCap must be at least
    Default: one query, four engines, two pages each8$0.0115
    Saturating discovery at maxPosts: 10050$0.0266
    Any run at the 200-page discovery ceiling200$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 saysWhat to change
Discovery found no post links at allEnable 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 repayNarrow 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 backLinkedIn is throttling or blocking. Residential proxy, and lower maxConcurrency.
Fetches are being retried — N proxied attempts per fetch, against 1.0 on a healthy runSame: residential proxy, lower maxConcurrency.
Pages were fetched but few produced a postThe 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 earnNarrow 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

SettingRequests per postNotes
Author pages only (searchEngines: [])~1.1One page per author, then one per post. Fastest and most reliable.
Web search + authors (default)~1.3Adds one request per query × engine × page.
scrapeComments: trueunchangedComments 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

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'])

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. 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 and Actor Terms 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 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.
  • 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.

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

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

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:

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