DEV Community

Cover image for Export everyone who engaged with a LinkedIn post — no login, no cookies
Andrew
Andrew

Posted on

Export everyone who engaged with a LinkedIn post — no login, no cookies

If you post on LinkedIn, the people who react and comment are the warmest leads you will ever get. They saw your thing, they cared enough to click, and they raised their hand in public.

Getting that list out of LinkedIn is annoying in three specific ways. This post is about making it one API call.

Why the usual approaches hurt

1. Most tools want your session cookie. You open DevTools, copy your li_at value, and paste it into someone else's server. Now your personal account is the one making automated requests, and if the tool gets rate-limited or flagged, it is your account that eats the restriction. That is a bad trade for a lead list.

2. Reactions and comments are usually two separate products. The two biggest tools in this niche sell "post comments" and "post reactions" as different scrapers. Someone who liked and commented shows up in both exports, so you run two jobs, download two files, and write dedup logic before you have anything usable.

3. The output is nested. You want a spreadsheet. You get JSON with a person object inside every row.

One call instead

I will use an Apify Actor that does reactions and comments in a single run and merges duplicate people. The endpoint below starts the run, waits, and returns the rows directly — no polling loop.

curl -X POST \
  "https://api.apify.com/v2/acts/data_pool~linkedin-post-engagement-scraper/run-sync-get-dataset-items" \
  -H "Authorization: Bearer $APIFY_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "postUrls": ["https://www.linkedin.com/feed/update/urn:li:activity:7491138591868469248/"],
    "engagementTypes": ["reactions", "comments"],
    "maxItemsPerPost": 100,
    "dedupePeople": true
  }'
Enter fullscreen mode Exit fullscreen mode

postUrls accepts normal post links, lnkd.in short links, or raw urn:li:activity:... IDs, so you can throw whatever format you already have at it.

What comes back

One object per person:

{
  "postUrl": "https://www.linkedin.com/feed/update/urn:li:activity:7491138591868469248/",
  "postAuthorName": "Jane Doe",
  "engagementTypes": ["reaction", "comment"],
  "reactionValue": "PRAISE",
  "commentText": "This matches what we're seeing too.",
  "commentedAt": "2026-07-01T12:00:00Z",
  "person": {
    "name": "John Smith",
    "headline": "VP Engineering @ Acme",
    "profileUrl": "https://www.linkedin.com/in/john-smith",
    "type": "individual"
  }
}
Enter fullscreen mode Exit fullscreen mode

Two things worth knowing about this shape, because they will bite you otherwise:

  • engagementTypes is an array. Someone who both reacted and commented has ["reaction", "comment"] in a single row rather than two rows. That is the dedup doing its job.
  • person.type is "individual" or "organization" — company pages react to posts too. If you are building an outreach list you almost certainly want to filter the organizations out.

Flattening it into a lead list

import os
import requests

ACTOR = "data_pool~linkedin-post-engagement-scraper"
URL = f"https://api.apify.com/v2/acts/{ACTOR}/run-sync-get-dataset-items"

def engagers(post_url, limit=100):
    resp = requests.post(
        URL,
        headers={"Authorization": f"Bearer {os.environ['APIFY_TOKEN']}"},
        json={
            "postUrls": [post_url],
            "engagementTypes": ["reactions", "comments"],
            "maxItemsPerPost": limit,
            "dedupePeople": True,
        },
        timeout=300,
    )
    resp.raise_for_status()

    for row in resp.json():
        person = row.get("person") or {}
        if person.get("type") != "individual":
            continue  # skip company pages
        yield {
            "name": person.get("name", ""),
            "headline": person.get("headline", ""),
            "profile_url": person.get("profileUrl", ""),
            "engagement": " + ".join(row.get("engagementTypes") or []),
            "comment": row.get("commentText", ""),
        }

for lead in engagers("https://www.linkedin.com/feed/update/urn:li:activity:7491138591868469248/"):
    print(lead)
Enter fullscreen mode Exit fullscreen mode

That is a CSV away from being usable.

One gotcha: this endpoint returns HTTP 408 if the run takes longer than 5 minutes. Keep maxItemsPerPost in the low hundreds, or switch to the async pattern (POST /runs, then poll) for genuinely large jobs.

Doing it without code

If you would rather wire it into a workflow, the same call works as a single HTTP Request node in n8n, which is handy when the destination is Google Sheets or Slack rather than your own script. The only setup is a Header Auth credential holding Bearer <token>.

What it costs

Per 1,000 people returned, at list prices as of August 2026:

Tool Reactions Comments Both from one post
This Actor $1.50 / 1k $1.50 / 1k one run
Alternative A (~6,000 users) $2.00 / 1k $2.00 / 1k two separate Actors
Alternative B (~4,800 users) $5.00 / 1k two separate Actors

The "both from one post" column is the part that actually matters. If you want reactors and commenters, the alternatives mean two runs and your own dedup pass, so the real gap is wider than the per-1000 numbers suggest.

Apify's free tier includes monthly credit, so a few thousand engagers costs nothing to try.

The honest caveats

  • Coverage is whatever LinkedIn surfaces for that post at that moment. Posts with tens of thousands of reactions come back partially. Nobody in this category gets 100%, whatever the marketing says.
  • Reactions have no timestamp. LinkedIn timestamps comments but not reactions, so commentedAt is empty for reaction-only rows. Do not build "engaged in the last 24h" logic on it.
  • This is personal data. Names, headlines, and profile URLs of identifiable people are personal data under GDPR and similar laws. Publicly visible is not the same as unrestricted — you still need a lawful basis to process it, and cold outreach in the EU has real requirements. Scraping it is the easy part; using it responsibly is the part worth thinking about.

Wrapping up

The pattern that made this simple was picking a tool that treats "everyone who engaged" as one dataset rather than two products, and using a sync endpoint so there is no polling loop. Both decisions removed more code than any clever scraping trick would have.


Disclosure: I built the Actor used in this post. The comparison figures are the list prices published on the Apify Store in August 2026; I have left the alternatives unnamed because the point is the structure of the pricing, not a swipe at anyone — the two tools referenced are easy to find and worth comparing for yourself. The caveats section is the same thing I would tell you if you asked me whether to buy it.

Top comments (0)