# Threads Scraper - Posts, Likes & Replies by Profile (`eiv/threads-profile-scraper`) Actor

Scrape posts from any public Threads profile with exact like, reply, repost and quote counts, full text, media URLs and true UTC timestamps. Pages back through years of history. No login, no API key. Tells you which profiles Threads will not serve.

- **URL**: https://apify.com/eiv/threads-profile-scraper.md
- **Developed by:** [Eimantas V](https://apify.com/eiv) (community)
- **Stats:** 3 total users, 2 monthly users, 100.0% runs succeeded, 0 bookmarks
- **User rating**: No ratings yet

## Pricing

from $1.20 / 1,000 post scrapes

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

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

## What's an Apify Actor?

An Actor is a serverless cloud program that runs on the Apify platform. It has two run modes.
In Batch mode, an Actor accepts a well-defined JSON input, performs an action which can take anything from a few seconds to a few hours,
and optionally produces a well-defined JSON output, datasets with results, or files in key-value store.
In Standby mode, an Actor provides a web server which can be used as a website, API, or an MCP server.

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

## How to integrate an Actor?

If asked about integration, you help developers integrate Actors into their projects.
You adapt to their stack and deliver integrations that are safe, well-documented, and production-ready.

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

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

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

# README

## Threads Scraper

Scrape posts from any public Threads profile — **by handle or URL** — with exact like, reply, repost and quote counts, the full post text, direct media URLs and true UTC timestamps. No Meta account, no API key, no access token.

One row per post, plus a summary row per profile carrying the exact follower count and bio.

***

### What you get on every post

| | |
|---|---|
| **Engagement** | likes, replies, reposts, quotes — exact integers, never rounded |
| **Content** | the full post text, exactly as written |
| **Media** | direct image URLs at full resolution, every frame of a carousel, and video URLs |
| **Timing** | a true UTC ISO 8601 timestamp, plus the raw epoch |
| **Thread structure** | whether a post is a reply, who it replied to, and which thread it belongs to |
| **Quotes** | the text and author of the post it quotes |
| **Author** | handle, display name, verified flag, and the account id that survives a rename |

And on every profile: **exact follower count**, bio, display name, verified status.

***

### Numbers, not prose

Threads' own profile page says `5.7M Followers`. That's a range half a million wide presented as a fact — and most exports hand it to you exactly like that, or parse it back into `5700000` and let you assume it's real.

This Actor reads the numbers from the underlying data, so that same profile reports **`5737159`**. The same is true of every engagement count: `likeCount: 3036` and `replyCount: 1743`, as integers, on every row.

One naming trap worth knowing about, because it silently breaks other exports: Threads has **no field called `reply_count`**. The real reply counter is stored under a different name entirely, and a scraper that reaches for the obvious one returns a column of nulls that reads as *"nobody replied to any of these posts"*.

***

### It goes back years, not one page

A Threads profile hands out its posts four at a time and then makes you ask for more. Plenty of scrapers stop at that first handful.

This one keeps going: measured on a real profile, it walked back **17 months of history** and was still finding more. Set `maxPostsPerProfile` to whatever depth you need, or give it a **`postedAfter`** date and let it stop there naturally.

Posts come back newest first, with a `rank` recording the original order.

***

### Filters that cut the bill, not just the spreadsheet

Filtered posts are **never written and never charged**. They aren't rows you pay for and then delete.

- **Include replies** — off by default, so you get original posts only. Turn it on and replies arrive marked with `isReply` and the handle they answered.
- **Minimum likes** — compared against the exact number, not a rounded one.
- **Only posts after this date** — also gives a deep run a natural stopping point.

***

### Honest about which profiles it cannot read

**This is the part other listings leave out.** Threads serves some profiles to logged-out visitors and refuses others. It is not random and not a temporary block: a refused profile is refused every time, and an accepted one works every time.

When Threads refuses, you get an error row saying exactly that — `errorClass: not-available-logged-out` — and **you are not charged for it**.

One thing this Actor deliberately will not do is guess. Threads answers **identically** for a real account it has restricted and for a handle that does not exist — same redirect, same page, same everything, verified across thirteen handles. So the row says the profile could not be read, and does not claim to know which of the two it was. No other listing on this shelf tells you this at all, and at least one states the opposite.

Expect most well-known handles to work. In a sample of ten real brand and creator accounts, seven were readable.

***

### An empty result always tells you why

Every profile gets a summary row, so a short result is never ambiguous:

| `zeroReason` | What it means |
|---|---|
| `profile-has-no-posts` | The profile was read successfully and genuinely has nothing |
| `all-posts-filtered-out` | Posts were found; **your filters** removed all of them |
| `charge-limit-reached` | Your charge limit stopped the run |
| `run-post-limit-reached` | The total-posts ceiling was reached first |
| `run-timeout-reached` | The run stopped itself early to finish cleanly rather than be killed |

The same row carries `stoppedOn`, `pagesFetched`, `postsScanned` and `postsFiltered`. If a profile stopped because it ran out of page budget rather than out of posts, `stoppedOnScanLimit` says so and the log names the setting to raise.

***

### One setting that matters: the proxy country

Threads shows a profile page to anyone, but it only answers the requests that page **past the first few posts** to European addresses. Measured across seven locations: US and Middle East egress returned the profile and then refused every continuation; EU egress returned full history.

So this Actor runs from a German residential address by default, and **you can leave the Proxy section alone**. Residential rather than datacenter is not a preference: Apify has no German datacenter addresses, so it is the only pool that can place the request in the EU at all.

If you do override it to a non-EU country, expect the first few posts of each profile and nothing more — and the run will say so plainly rather than leaving you to wonder.

***

### Honest about limits

- **Threads publishes no view counts.** Not for posts, not for videos — the number does not exist in the data, so no scraper can give it to you.
- **Media URLs expire.** Image and video links are signed by Meta's CDN. Download promptly rather than storing the URL.
- **Total post count is not exact.** Threads only states it rounded, so it is deliberately not reported as a number.
- **A quoted post is not a row of its own.** It belongs to a different author. The quoting post carries its text and handle instead — counting it would inflate both your dataset and your bill.

***

### Pricing

Pay per result. No monthly rental on top.

| Event | Price per 1,000 | What triggers it |
|---|---|---|
| **Post scraped** | **$2.00** | One post written to your dataset |
| **Profile opened** | **$2.00** | One profile Threads actually served |

Prices fall with your Apify plan, down to **$1.20 / $1.20** per 1,000 on Gold.

**What is never charged:**

- A profile Threads refused to serve — the case above
- Posts removed by your replies, minimum-likes or date filters
- The same post seen twice in one run, even across two spellings of the handle
- Quoted posts belonging to other authors
- Summary rows and error rows
- A request that failed and had to be retried

***

### Getting started

1. Put one or more profiles in **Threads profiles** — handles or URLs, one per line.
2. Set **Max posts per profile**. The first few arrive with the profile itself; after that it pages in tens.
3. Leave **Include replies** off for a first run to see original posts only.
4. Run it. Results appear as they are collected — the **Posts** view is one row per post, the **Run report** view is the per-profile summary.

Leave the profile list empty and it runs a small demo on `@zuck`, so you can see the output format before spending anything meaningful.

# Actor input Schema

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

Profiles to scrape, one per line. Accepts a handle (@zuck or just zuck) or a profile URL (https://www.threads.com/@zuck). A link to a single post is rejected rather than quietly turned into a whole-profile scrape. Leave empty to run a small demo on @zuck.

## `maxPostsPerProfile` (type: `integer`):

Stop after this many posts from each profile. The first page arrives with the profile itself; after that Threads returns about 10 posts per request, so 100 posts is roughly seven requests.

## `maxTotalPosts` (type: `integer`):

A ceiling across every profile in the run, so one prolific account cannot spend the whole budget. Counted on rows actually written, and a post that appears under two of your inputs is only counted, written and charged once.

## `maxPagesPerProfile` (type: `integer`):

A work limit rather than an output limit: pages READ, not rows kept. It matters when a strict filter drops nearly everything, where without it a run could walk a whole profile to find ten rows. At about 10 posts a page the default reaches roughly 1,000 posts. If a profile stops on this limit the summary row says so, so it is never mistaken for the profile running out of posts.

## `includeReplies` (type: `boolean`):

Off by default. A Threads profile feed mixes original posts with the author's replies to other people. Leaving this off keeps only original posts, which is what most analyses want; turning it on adds the replies, each marked isReply with the username it replied to. Filtered posts are never written and never charged.

## `minLikes` (type: `integer`):

Skip posts below this like count. Compared against the exact number from the payload, not a rounded display figure.

## `postedAfter` (type: `string`):

Keep only posts published on or after this date. Posts are returned newest first, so this also gives the run a natural stopping point. Left empty, the run goes as deep as your other limits allow.

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

How many profiles may be in flight at once. Raising this does not speed up a run on its own, because the delay below paces requests globally rather than per worker.

## `requestDelayMs` (type: `integer`):

Milliseconds between the START of one request and the next, counted across the whole run rather than per worker. Lowering it risks blocks that cost retries and return less data, not more.

## `proxyConfig` (type: `object`):

Leave this alone unless you have a reason not to. Threads serves a profile page to anyone, but it only serves the requests that page PAST the first few posts to European addresses - measured across seven locations, where US and Middle East egress returned the profile and then refused every continuation, and EU egress returned full history. So this Actor pins the proxy country to Germany by default. Changing it to a non-EU country will usually get you the first few posts of each profile and nothing more, and the run will say so.

## Actor input object example

```json
{
  "profiles": [
    "@zuck"
  ],
  "maxPostsPerProfile": 100,
  "maxTotalPosts": 1000,
  "maxPagesPerProfile": 100,
  "includeReplies": false,
  "minLikes": 0,
  "maxConcurrency": 3,
  "requestDelayMs": 1000,
  "proxyConfig": {
    "useApifyProxy": true
  }
}
```

# Actor output Schema

## `dataset` (type: `string`):

One row per post with its full text, a true UTC timestamp and exact like, reply, repost and quote counts, plus direct media URLs. Every profile also gets a summary row carrying the exact follower count and bio, saying how many posts were read and why the run stopped.

# API

You can run this Actor programmatically using our API. Below are code examples in JavaScript, Python, and CLI, as well as the OpenAPI specification and MCP server setup.

## JavaScript example

```javascript
import { ApifyClient } from 'apify-client';

// Initialize the ApifyClient with your Apify API token
// Replace the '<YOUR_API_TOKEN>' with your token
const client = new ApifyClient({
    token: '<YOUR_API_TOKEN>',
});

// Prepare Actor input
const input = {
    "profiles": [
        "@zuck"
    ]
};

// Run the Actor and wait for it to finish
const run = await client.actor("eiv/threads-profile-scraper").call(input);

// Fetch and print Actor results from the run's dataset (if any)
console.log('Results from dataset');
console.log(`💾 Check your data here: https://console.apify.com/storage/datasets/${run.defaultDatasetId}`);
const { items } = await client.dataset(run.defaultDatasetId).listItems();
items.forEach((item) => {
    console.dir(item);
});

// 📚 Want to learn more 📖? Go to → https://docs.apify.com/api/client/js/docs

```

## Python example

```python
from apify_client import ApifyClient

# Initialize the ApifyClient with your Apify API token
# Replace '<YOUR_API_TOKEN>' with your token.
client = ApifyClient("<YOUR_API_TOKEN>")

# Prepare the Actor input
run_input = { "profiles": ["@zuck"] }

# Run the Actor and wait for it to finish
run = client.actor("eiv/threads-profile-scraper").call(run_input=run_input)

# Fetch and print Actor results from the run's dataset (if there are any)
print(f"💾 Check your data here: https://console.apify.com/storage/datasets/{run.default_dataset_id}")
for item in client.dataset(run.default_dataset_id).iterate_items():
    print(item)

# 📚 Want to learn more 📖? Go to → https://docs.apify.com/api/client/python/docs/quick-start

```

## CLI example

```bash
echo '{
  "profiles": [
    "@zuck"
  ]
}' |
apify call eiv/threads-profile-scraper --silent --output-dataset

```

## MCP server setup

```json
{
    "mcpServers": {
        "apify": {
            "type": "http",
            "url": "https://mcp.apify.com/?tools=fetch-actor-details,eiv/threads-profile-scraper"
        }
    }
}
```

The hosted server signs you in with OAuth on first connect, so no API token belongs in this config. Clients without OAuth support can send an `Authorization: Bearer <APIFY_API_TOKEN>` header instead, using a token from API & Integrations in Apify Console (https://console.apify.com/settings/integrations).

## OpenAPI specification

Download the OpenAPI definition: https://api.apify.com/v2/actors/aGNsKqVBeFZIMMVJ7/builds/d9w7AlxD2fPAbrfkN/openapi.json
