Skip to content

ChocoData-com/linkedin-post-scraper

Repository files navigation

LinkedIn Post Scraper

LinkedIn Post Scraper

LinkedIn Post Scraper for extracting post content, like and comment counts, hashtags, media and authors from LinkedIn.com. This repo has a free LinkedIn post scraping script you can run right now, and a LinkedIn post data API returning 12 fields of real structured JSON per post.

This repo reads public posts published by organisations and by public figures posting in a professional capacity. Every capture, example and screenshot on this page targets such a post. It does not read comment threads, and it collects nothing about the members of the public who reacted to or commented on a post. More on that in Scope.

Last updated: 2026-07-20. Working against LinkedIn.com as of July 2026, and re-verified whenever LinkedIn changes their markup.

This repo covers one endpoint in depth. For LinkedIn jobs, companies and member profiles, see the LinkedIn Scraper repo.

Every JSON block on this page was captured from the live API on 2026-07-20. Long strings are truncated where marked, and each block says exactly what was cut; every field shown is verbatim. Full uncut samples are committed in linkedin_post_scraper_api_data/, so you can diff this page against them. Every code example calls the actual API and is runnable from linkedin_post_scraper_api_codes/.

pip install requests
export CHOCODATA_API_KEY="your_key"     # free: 1,000 requests, one-time, no card
python linkedin_post_scraper_api_codes/post.py "https://www.linkedin.com/posts/..."

Those three lines return this, live from LinkedIn.com (5 of the 12 fields, verbatim; the full object is below):

{
  "postId": "7142740845631365120",
  "postedAt": "2023-12-19T05:02:03.268Z",
  "engagement": {
    "likes": 4445,
    "comments": 367,
    "shares": null,
    "views": null
  },
  "hashtags": [],
  "socialPlatform": "linkedin"
}

...for any public post you point it at, with 12 fields each:

Retrieved LinkedIn post data

That is the whole point of this repo. The rest of this page is the free path, what a public post page actually publishes, and the endpoint reference.


Contents


Scope: public posts only

A LinkedIn post is published deliberately, to be read. The people who comment underneath one did not sign up to be a dataset, and that difference shapes what this repo is willing to be.

What it does: read one public post. The text the author published, the like and comment counts, hashtags, attached media, the author's name and page, and an exact publish timestamp.

What it does not do: read the comment thread. This is a deliberate omission rather than a technical limit, and it is worth being precise about, because the page hands the data over freely. The public post page carries a comment array in its structured data, with comment bodies and the commenters' names, on 4 of the 4 posts we sampled. Both the endpoint and the free script in this repo skip it. comments[] in the response is therefore always empty, and engagement.comments gives you the count without the people.

The demand for the other thing is real and easy to see: Google's autocomplete for this topic offers "linkedin post comment scraper", "scrape linkedin post comments" and "linkedin post commenter and liker scraper". None of that is here.

This repo is built for what an organisation chose to publish. The audience underneath it is out of scope by design.


Free LinkedIn Post Scraper

A public LinkedIn post page carries a schema.org SocialMediaPosting block in a <script type="application/ld+json"> tag. That block holds the post text, the publish timestamp, the author and the interaction counters, so a post can be read without a headless browser, JavaScript rendering, or a login. No key, no cost:

python free_scraper/linkedin_post_free_scraper.py "https://www.linkedin.com/posts/..."

Source: free_scraper/linkedin_post_free_scraper.py. It finds the posting node in the JSON-LD, reads the counters off interactionStatistic, and parses hashtags out of the post copy.

After running the command, your terminal should look something like this:

Free LinkedIn post scraper output

It works. From a clean residential IP on 2026-07-20, across the 4 public post URLs committed in this repo: 16 of 16 sequential requests returned a parseable SocialMediaPosting block, and so did 24 of 24 at 8 concurrent and 48 of 48 at 16 concurrent, the last of those in 7.5 seconds. No 429s at the volumes we tested. Every request is recorded in free_scraper_runs.json.

So read the next section before you decide, because the interesting constraints on this surface are not access constraints.

What a public LinkedIn post page publishes

The whole post lives in one JSON-LD block, and what that block contains is the ceiling for any tool reading this page, ours included. We read it directly off 4 public posts on 2026-07-20:

What a public post page publishes about itself

What bites you Why What it costs you
There are only two counters The page publishes LikeAction and CommentAction and nothing else. On 4 of 4 posts there was no ShareAction and no ViewAction anywhere in the structured data. Share and view counts are not obtainable from this page by any means, so shares and views come back null. Plan your metrics around likes and comments.
"Sign in to view" is on every page The phrase appears exactly once in the chrome of all 4 posts, including the ones that carry the complete post JSON. A detector that greps the body for sign-in language fires on responses that were never walled. You debug a block that never happened, or you trust a detector that cannot fail. Parse first, and only call it a wall when the post is genuinely absent.
You get a page, not a record The pages we measured ran from 110,875 to 201,399 characters of HTML. The fields you actually wanted are about 1 KB of it. You write and maintain a parser, and re-do it every time LinkedIn reshapes the page.
The JSON-LD shape is not guaranteed LinkedIn emitted SocialMediaPosting on 4 of 4 today, but the same surface also appears as DiscussionForumPosting, Article and BlogPosting, sometimes wrapped in a @graph. Media is another split: 3 of 4 posts carried an image key in the JSON-LD, and all 4 carried an og:image. A parser written against one shape silently returns null when another shows up. Both scripts in this repo accept all four of those @type values and unwrap @graph, which is most of what they are.
A dead URL still returns a full page An activity id that does not resolve comes back as HTTP 404 carrying a 319,687-character page titled LinkedIn, with no posting JSON in it. The status code is the only clean signal that the post is gone. A parser that tests for parse failure rather than for status cannot tell "this post no longer exists" from "my parser broke", and keeps retrying something that will never succeed.

One more thing worth reading rather than paraphrasing: LinkedIn's own robots.txt disallows all of it. The User-agent: * block, in its entirety, is two lines: User-agent: * followed by Disallow: /. Everything. The file carries 76 User-agent: blocks in total, so the other 75 name specific crawlers LinkedIn has chosen to treat differently. It also states that the use of robots or other automated means to access LinkedIn without the express permission of LinkedIn is strictly prohibited, and points to LinkedIn's user agreement (linkedin.com/robots.txt, retrieved 2026-07-20). That applies to the free script above and to the API below equally. It is your call to make, and worth making with the actual text in front of you rather than discovering it later.


Using the Chocodata LinkedIn Post Scraper API

The managed option, and the one this repo is built around. The Chocodata LinkedIn Post Scraper API returns the post as parsed JSON in a stable schema, accepts both URL forms and the four @type shapes LinkedIn emits on this surface, answers a URL that no longer resolves with a clean 404 instead of a page, and runs at your plan's concurrency without proxy management. Free for the first 1,000 requests.


LinkedIn Post Scraper API reference

Below is the LinkedIn Post Scraper API reference to get you started: authentication, the parameters, error handling, concurrency, and the endpoint itself.

Quickstart

curl "https://api.chocodata.com/api/v1/linkedin/post?api_key=YOUR_KEY&url=https://www.linkedin.com/posts/williamhgates_the-road-ahead-reaches-a-turning-point-in-activity-7142740845631365120-Mx61"
import requests

r = requests.get(
    "https://api.chocodata.com/api/v1/linkedin/post",
    params={"api_key": "YOUR_KEY",
            "url": "https://www.linkedin.com/posts/williamhgates_the-road-ahead-"
                   "reaches-a-turning-point-in-activity-7142740845631365120-Mx61"},
    timeout=90,
)
p = r.json()
print(p["postAuthor"]["authorName"], p["engagement"]["likes"], p["postedAt"])
# Bill Gates 4445 2023-12-19T05:02:03.268Z

After running the command, your terminal should look something like this:

Running the LinkedIn Post Scraper API

Get a key at chocodata.com (1,000 requests, one-time, no card).

Authentication

Pass api_key as a query parameter on every request. That is the whole auth model. No OAuth, no LinkedIn login, no session cookie: you never hand us or LinkedIn an account.

Parameters

Param Type Required Default Description
api_key string yes - Your Chocodata API key.
url string (URL) yes - A public post URL. Both forms work and return the same object: the share form linkedin.com/posts/<slug>-activity-<id>-<code> and the feed form linkedin.com/feed/update/urn:li:activity:<id>.
add_html boolean no false Also return the upstream HTML alongside the parsed JSON, for debugging a parse. Leave it off in production.

Each request costs 5 credits (= 1 request). Responses are billed only on success (2xx).

Errors

The two bodies shown below are captured verbatim from real calls. Nothing here is billed: you are only charged on a 2xx.

Status error code Meaning Billed What to do
400 invalid_params url is missing or the wrong type. The body names the exact path that failed. no Fix the query string.
401 INVALID_API_KEY Key missing, unrecognised, or revoked. no Check api_key. Get one at chocodata.com.
402 INSUFFICIENT_CREDITS Balance exhausted. no Top up or upgrade at chocodata.com.
404 item_not_found No post at that URL: deleted, made private, or a wrong activity id. retryable: false. no Fix the URL. Retrying will not help.
429 RATE_LIMITED Over your plan's concurrency. no Back off and retry; see Rate limits.
502 target_unreachable LinkedIn refused every attempt for this request. retryable: true. no Retry shortly.

The 404 is the one worth wiring up properly, because it is the difference between "this post is gone" and "try again later": item_not_found carries retryable: false, while 502 target_unreachable carries retryable: true. One of them should stop your loop and the other should not. The message names the cause and confirms you were not charged: The target returned 404 for this request - the item or identifier does not exist. Check the id or URL. You were not charged.

Two response shapes exist: auth and billing errors nest under error.code in uppercase, while scrape-layer errors are flat with a lowercase error string. The 401 and 400 bodies are committed in full in errors.json, along with the status, message and retryable flag recorded for the 404.

The scripts in this repo map every documented status onto an actionable message, so a typo'd key does not hand you a stack trace:

LinkedIn Post Scraper API error handling

A bad key, verbatim:

curl "https://api.chocodata.com/api/v1/linkedin/post?api_key=totally_invalid_key_123&url=https://www.linkedin.com/posts/..."
{"error":{"code":"INVALID_API_KEY","message":"Api key not recognised."}}

A missing parameter, verbatim. It names the exact path that failed:

{
  "error": "invalid_params",
  "issues": [
    {
      "code": "invalid_type",
      "expected": "string",
      "received": "undefined",
      "path": ["url"],
      "message": "Required"
    }
  ]
}

Rate limits and concurrency

There is no per-minute request cap. The limit is concurrency: how many requests you may have in flight at once.

Plan Concurrent requests
Free 10
Vibe 30
Pro 50
Custom 100 to 500+

Exceed it and you get 429, not a queue. The endpoint is a synchronous GET: there is no webhook, callback, or async job to poll. The examples use timeout=90 because a request that has to be re-attempted upstream takes longer than the median (see Measured latency).

Sizing: at Pro (50 concurrent) and the measured 3.0s median, one worker pool sustains roughly 50 / 3.0 = 16 requests per second. Fan out with a thread pool:

from concurrent.futures import ThreadPoolExecutor
import requests

def one(url):
    r = requests.get("https://api.chocodata.com/api/v1/linkedin/post",
                     params={"api_key": KEY, "url": url}, timeout=90)
    return url, (r.json()["engagement"]["likes"] if r.ok else None)

with ThreadPoolExecutor(max_workers=10) as pool:   # <= your plan's concurrency
    for url, likes in pool.map(one, post_urls):
        print(likes, url)

Post: post content, engagement counts, hashtags and media

One public LinkedIn post by URL: text content, like and comment counts, hashtags, media, the author, and an exact publish timestamp.

Param Type Required Default Description
url string (URL) yes - A public post URL, in either the share or the feed form.
curl "https://api.chocodata.com/api/v1/linkedin/post?api_key=YOUR_KEY&url=https://www.linkedin.com/posts/williamhgates_the-road-ahead-reaches-a-turning-point-in-activity-7142740845631365120-Mx61"

Real response for a public post by Bill Gates. All 12 fields verbatim, empty arrays and nulls included; only media[].mediaUrl is truncated where marked, because it is a 191-character signed CDN URL (full sample):

{
  "content": "As 2023 comes to a close, I find myself thinking more than ever about the world today’s young people will inherit. I’m lucky my work gives me insight into all the ways the world is getting better, so I wanted to share why I’m optimistic about the year ahead. https://lnkd.in/dHaPf45E",
  "content_markdown": null,
  "engagement": {
    "likes": 4445,
    "comments": 367,
    "shares": null,
    "views": null
  },
  "hashtags": [],
  "media": [
    {
      "mediaType": "image",
      "mediaUrl": "https://media.licdn.com/dms/image/sync/v2/D4D27AQFTRJKaj65uIw/articleshare-shrink_800/articl...",
      "mediaDescription": "image"
    }
  ],
  "mentions": [],
  "comments": [],
  "postAuthor": {
    "authorId": null,
    "authorName": "Bill Gates",
    "authorProfileLink": "https://www.linkedin.com/in/williamhgates",
    "authorUsername": "williamhgates"
  },
  "postId": "7142740845631365120",
  "postLink": "https://www.linkedin.com/posts/williamhgates_the-road-ahead-reaches-a-turning-point-in-activity-7142740845631365120-Mx61",
  "postedAt": "2023-12-19T05:02:03.268Z",
  "socialPlatform": "linkedin"
}

engagement.likes paired with postedAt is what makes this worth automating: an exact ISO timestamp against a like count lets you measure engagement decay, which the page itself never shows you. LinkedIn renders a rounded age on the post (2y for this one, as of the capture date); the API hands you 2023-12-19T05:02:03.268Z, to the millisecond.

Note that this endpoint returns camelCase keys (postAuthor, postId, postedAt) while the rest of the API is snake_case. Code against that.

Field notes:

  1. engagement.likes is the page's LikeAction counter and engagement.comments is its CommentAction counter. Both are read straight off the structured data the page publishes about itself, so they match what a logged-out visitor sees. On this post they are 4,445 and 367.
  2. shares and views are null, on every post. This is not a parse gap: the public post page carries no share or view counter at all. We read the structured data directly on 4 posts and found LikeAction and CommentAction on 4 of 4, and ShareAction and ViewAction on none of them. No tool reading this page can return those two numbers, so build your reporting on likes and comments.
  3. comments[] is an array of comment objects, and it is always empty here. The field exists because the schema has a place for the thread; this repo does not fill it. See Scope. Use engagement.comments for the count.
  4. mentions[] is always an empty array, and hashtags is parsed out of the post copy. Any #Token in content comes back without the #, deduplicated and in the order it appeared. All four posts committed here were published without hashtags, so hashtags is [] on every one of them; linkedin_post_free_scraper.py applies the same rule, so you can point it at a post that uses them and compare.
  5. postLink echoes the URL you sent, and nothing else changes. We fetched the same post through both URL forms: every field came back identical except postLink, which is your input with the query string stripped. Use postId as the join key, never postLink. Both captures are committed (post.json and post_feed_url_form.json) so you can diff them.
  6. postAuthor resolves a company page or a member. authorProfileLink is a /company/<slug> URL when an organisation published the post and a /in/<slug> URL when a person did; authorUsername is the slug either way. authorId was null on all 4 posts we sampled, so do not use it as a key.
  7. content is whitespace-normalised, and content_markdown is null on every post we captured. The copy comes back as a single line: paragraph breaks in the original are collapsed to spaces. Microsoft Community's post is 302 characters with two newlines in the page's own structured data and 300 characters with none in content (posts.json). Plan on content for the words, not for the layout.

A note on the example itself: it is a public post from December 2023 that has stayed up, which is why it is used throughout this page and in urls.txt. Any post URL works in its place, and if that one is ever taken down the endpoint returns 404 item_not_found rather than stale data, so a pipeline built on it fails loudly rather than quietly.

Runnable: linkedin_post_scraper_api_codes/post.py


Track engagement on a set of LinkedIn posts

Watching what a set of posts does over time is the main reason to scrape LinkedIn posts, so that use case is in the repo end to end rather than as a snippet. engagement_tracker.py reads a list of post URLs, fetches each one, stores every observation in a local SQLite dataset, and prints what moved since the last run:

python linkedin_post_scraper_api_codes/engagement_tracker.py linkedin_post_scraper_api_codes/urls.txt

LinkedIn post engagement tracker output

That is a real first run, which has nothing to compare against and says so, followed by a real second run seconds later. Three of the four posts had not moved in that time. GitHub's had, by 4 likes, which is the whole point of the diff. Like counts on an active post drift continuously, which is also why that post reads 1,187 in the table near the top of this page and 1,299 by the time the tracker first saw it: three observations, minutes apart, all real. On a daily cron those same lines become the engagement curve for every post in the list.

One API request per post per run, so the arithmetic is simple: 10 posts checked daily is 10 requests a day, and the 1,000 free requests cover about 100 days of that, one-time. A post that fails is printed with its status and counted in the run summary, and the run carries on rather than dying. No observation row is written for a failed post, so the series skips that run for that post and resumes at the next one: 502 is retryable and will, 404 is not and will not.

Export the dataset whenever you want, no lock-in:

sqlite3 -header -csv linkedin_posts.db "SELECT * FROM observations;" > posts.csv

Because every run appends rather than overwrites, the table is a time series from the first day you run it. Point it at your own posts and a set of competitors' and the diff is the report.


Measured latency

Real end-to-end wall-clock, measured from a laptop against the live API on 2026-07-20 across 5 public post URLs. This includes the upstream fetch, the anti-bot handling, and the parse:

Endpoint Median Range n
/linkedin/post 3.0s 1.9 to 7.9s 5

All 5 returned 200. Read the range, not just the median. The 7.9s call is the interesting number, and the wider session that produced this page also recorded one at 13.8s: requests that have to be re-attempted upstream land at that end of the spread, and they still came back with real data. Absorbing those attempts, silently, is a good part of what you are paying for. Small sample from one laptop on one afternoon, so size your timeouts off the tail rather than the median, and reproduce it with the scripts in this repo.


License

MIT. See LICENSE.