# X Twitter Scraper - Profiles, Posts, Engagement (`s-r/x-twitter-scraper`) Actor

Read X (Twitter) accounts without an account of your own: full profile with follower counts, plus recent posts with likes, reposts, replies, quotes, bookmarks and view counts. Individual posts can also be fetched by ID.

- **URL**: https://apify.com/s-r/x-twitter-scraper.md
- **Developed by:** [SR](https://apify.com/s-r) (community)
- **Categories:** Social media, Automation
- **Stats:** 2 total users, 1 monthly users, 100.0% runs succeeded, 0 bookmarks
- **User rating**: No ratings yet

## Pricing

Pay per event

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

## X (Twitter) Scraper

Read X three ways: by **handle**, by **post ID**, or by **search term**. You get
the full profile with follower counts, plus posts carrying likes, reposts,
replies, quotes, bookmarks and **view counts**.

No login, no cookies, no API key.

### What you get

**Per account**, in the run summary: follower count, following count, total
posts, bio, location, join date, verification, profile and banner images.

**Per post**, one row each:

- `text`, untruncated, and `created_at`
- `like_count`, `retweet_count`, `reply_count`, `quote_count`,
  `bookmark_count` and `view_count`
- `is_retweet` and `is_quote`, so reposts can be separated from original posts
  in one filter
- `conversation_id`, which lets replies be grouped back into threads
- `hashtags`, `mentions`, `links` and `media_urls`
- `lang`, `username`, `user_id`, `user_followers` and a direct `url`

View counts are the one most people are surprised to get: X publishes them on
posts but not on every surface, and they come through here.

### Advanced-search operators

X's own search syntax works as input, the one documented at
`igorbrigadir/twitter-advanced-search`. Paste a query you already use and it is
parsed rather than treated as literal words.

**Honoured, and how:**

| operator | how it is served |
|---|---|
| `from:` | **routed** — that account's timeline is read directly, which is better data than finding the same posts by search |
| `lang:` `since:` `until:` `since_time:` `until_time:` | applied to the results |
| `min_faves:` `min_retweets:` `min_replies:` | applied to the results |
| `conversation_id:` `url:` | applied to the results |
| `filter:` / `-filter:` / `exclude:` | `images`, `videos`, `media`, `quote`, `links`, `replies`, `nativeretweets` |

**Not honoured, and reported rather than dropped:** the geo family (`geocode:`
`near:` `within:` `place:`) because no row carries coordinates; the card family
and `source:` because those fields are not published on the surfaces read here;
`list:` because list timelines need a signed-in session; and `to:`
`quoted_tweet_id:` `quoted_user_id:` `max_id:` `since_id:`.

If you pass one of those, the run summary lists it under `operatorsIgnored`
with a note saying the results are **wider than your query asked for**. That is
the whole point of reporting it: an operator that vanishes quietly gives you
rows you believe were filtered.

The same filters are also available as ordinary form fields (author, language,
minimum likes, date range, only-images and so on) if you would rather not write
the syntax. Both spellings are the same vocabulary and can be mixed; a operator
written in a term beats a form field of the same name.

### How search works here, and what that costs you

X refuses its own search to anyone who is not signed in. Twelve published
query ids for its search endpoint all return "not found", and every older REST
search path is gone. So search cannot be done on X directly.

What this Actor does instead is a two-step: Yahoo supplies the URLs of posts
matching your phrase, and then **each post is read from X itself**.

Both halves are free. Yahoo is read directly, so there is no API key to obtain
and no metered service in the middle: a run costs its own requests and nothing
else.

That distinction is the important one:

- **The data is first-hand.** Likes, reposts, views and text all come from X at
  the moment of the run, not from a cached search snippet.
- **The discovery is second-hand.** You get posts a search engine has indexed,
  which skews toward posts that have been public for a while. Something posted
  in the last hour will usually not appear.

Every row records which it was in a `discovery` field: `x-timeline`, `tweet-id`
or `search-engine`. Nothing is implied, so a mixed run stays honest.

The run summary reports `tweetsDiscoveredBySearch` alongside
`searchHydrationRate`, the share of discovered posts that still resolved. A
search index outlives deleted posts, so that number is rarely 100%, and seeing
it is better than quietly getting fewer rows than you asked for. A typical run
lands between 60% and 100%.

### Fetching single posts

A target made only of digits is treated as a post ID rather than a handle,
which is unambiguous because X handles cannot be all digits. That path uses a
different door and needs no session at all, so it is well suited to bulk
hydration: give it a list of IDs you already have and it fills in the text and
engagement for each.

Fewer fields come back that way (reposts, quotes, bookmarks and views are not
published on that surface), which the empty columns will show you honestly.

### Protected accounts

A protected account returns its profile and no posts. That is the account's own
setting, not a failure, so it is reported as such: the profile appears in the
summary and the handle is listed under protected accounts rather than under
errors.

### Input

- **Handles or post IDs** — `nasa`, `@openai`, or a numeric ID
- **Search terms** — `web scraping`, `nasa artemis`. Mix these with handles in
  the same run; they are separate fields so an ambiguous word like `apify` is
  never guessed at.
- **Maximum posts per search term** and **search pages per term**
- **Maximum posts per account** — pages are walked until this is reached. Set
  it to **0 for profiles only**, which turns each account into a single
  request: three accounts came back in about five seconds that way, against ten
  for the same three at forty posts each.
- **Maximum targets**, **targets in parallel** and **retries**
- **Request from country**, optionally

Keep parallelism modest. Each account is a profile lookup followed by several
sequential page requests, so concurrency multiplies quickly.

### Run summary

Targets requested, profiles found, posts returned, protected accounts, total
likes and total views across everything collected, and how many posts were
reposts rather than original.

### What people use this for

**Competitor and brand monitoring.** Follower counts alongside per-post
engagement, collected on a schedule, show both audience growth and what
actually lands.

**Engagement benchmarking.** Because view counts come through, engagement can
be measured as a rate rather than as a raw like count, which is the only
version of that number that compares across accounts of different sizes.

**Thread reconstruction.** `conversation_id` groups a thread back together
from the posts you collected.

**Bulk post hydration.** If you already have post IDs from somewhere else,
feeding them in fills in text and engagement for each.

### Scale and cost

A profile lookup is one request. Each page of posts is another and returns
about 20, so an account at 40 posts costs three requests in total. Two accounts
at 40 posts each returned 81 rows in 46 seconds in testing, including one
target that did not exist and was reported as an error.

Profiles-only runs are much cheaper: setting posts per account to 0 makes each
target a single request, which is the right setting when you are tracking
follower counts across a long list.

Nothing needs configuring and nothing needs to be kept alive between runs:
there is no key to obtain, no account to connect and no credential to rotate.

### A note on what this reads

Everything here is what a signed-out visitor can see: public accounts, public
posts, public counts. Protected accounts return a profile and no posts, exactly
as they would to any visitor, and no attempt is made to see past that.

### What this is not

There are actors on the Store that reach 30 to 80 posts per second and offer
Top/Latest sorting. They get that by driving X's own search with signed-in
sessions. This one reads what a signed-out visitor can see, so it is slower and
has no sort control, and it will not pretend otherwise.

Where it is competitive: reading named accounts, where the timeline door gives
full first-hand data, and applying the filters above to whatever it collects.

### Notes

Engagement moves quickly on fresh posts, so two runs minutes apart will
legitimately disagree. That difference is the data rather than an error.

Reposts appear as rows with `is_retweet` true and, typically, zero likes of
their own: the engagement sits on the original post, not on the repost. Filter
on that flag rather than treating those as underperforming posts.

Deleted posts and suspended accounts return nothing and are reported as errors
naming the target rather than dropped silently from the output.

If X ever changes the endpoint this reads, every target fails at once. That
case is reported as its own error rather than as "none of your accounts exist",
and the run summary flags it, so a failed run tells you whether the problem is
your input or ours.

Rows are deduplicated on the post ID within a run.

# Actor input Schema

## `targets` (type: `array`):

Accounts to read, as handles such as nasa or @nasa, and individual tweets as their numeric ID. Handles return the profile plus recent posts; an ID returns that single tweet. Note that X does not serve keyword search to a signed-out session, so this reads accounts you name rather than finding them.

## `search_terms` (type: `array`):

Phrases to search for. X's advanced-search operators are understood: from:, lang:, since:, until:, min\_faves:, min\_retweets:, min\_replies:, conversation\_id:, url:, and filter:/-filter: for images, videos, media, quote, links, replies and nativeretweets. from: reads that account's timeline directly. Operators this Actor cannot serve (the geo, card and list families) are reported in the run summary as ignored rather than silently dropped.

## `tweets_per_term` (type: `integer`):

Upper bound on posts returned for each search term.

## `search_pages` (type: `integer`):

How many pages of search results to walk when discovering posts. More pages find more posts but reach further back.

## `tweets_per_user` (type: `integer`):

How many recent posts to collect per handle. X serves about 20 per page and this walks pages until the limit is reached. Set to 0 for profiles only, which is much faster.

## `max_targets` (type: `integer`):

How many handles or IDs to process in one run.

## `concurrency` (type: `integer`):

How many accounts to read at the same time. Keep this modest: each account is several sequential page requests.

## `retries` (type: `integer`):

Retries before a target is reported as an error. Each retry starts a fresh guest session, which is what clears a spent one.

## `country` (type: `string`):

Optional two-letter country code to request from, for example us. Leave empty to request from wherever the run happens to sit.

## `author` (type: `string`):

Only posts by this handle. Same as writing from: in a search term, and it reads that account's timeline directly.

## `mentioning` (type: `string`):

Only posts mentioning this handle.

## `tweetLanguage` (type: `string`):

Only posts in this language, as a two-letter ISO 639-1 code.

## `minimumFavorites` (type: `integer`):

Only posts with at least this many likes.

## `minimumRetweets` (type: `integer`):

Only posts with at least this many reposts.

## `minimumReplies` (type: `integer`):

Only posts with at least this many replies.

## `start` (type: `string`):

Only posts from this date onward, as YYYY-MM-DD.

## `end` (type: `string`):

Only posts up to this date, as YYYY-MM-DD.

## `onlyImage` (type: `boolean`):

Only posts with images

## `onlyVideo` (type: `boolean`):

Only posts with video

## `onlyQuote` (type: `boolean`):

Only quote posts

## `onlyVerifiedUsers` (type: `boolean`):

Only posts from accounts carrying a verified badge.

## `maxItems` (type: `integer`):

Hard cap on rows returned by the whole run, applied after every other filter.

## Actor input object example

```json
{
  "targets": [
    "nasa",
    "openai"
  ],
  "search_terms": [
    "web scraping"
  ],
  "tweets_per_term": 30,
  "search_pages": 2,
  "tweets_per_user": 40,
  "max_targets": 50,
  "concurrency": 3,
  "retries": 3,
  "country": "us",
  "author": "nasa",
  "mentioning": "openai",
  "tweetLanguage": "en",
  "start": "2026-01-01",
  "end": "2026-09-01",
  "onlyImage": false,
  "onlyVideo": false,
  "onlyQuote": false,
  "onlyVerifiedUsers": false
}
```

# Actor output Schema

## `posts` (type: `string`):

One row per post.

## `summary` (type: `string`):

The full profile records, engagement totals and any protected accounts.

## `errors` (type: `string`):

Targets that could not be read, with a code and a redacted message.

# 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 = {
    "targets": [
        "nasa",
        "openai"
    ],
    "search_terms": [
        "web scraping"
    ],
    "tweets_per_term": 30,
    "search_pages": 2,
    "tweets_per_user": 40,
    "max_targets": 50,
    "concurrency": 3,
    "retries": 3
};

// Run the Actor and wait for it to finish
const run = await client.actor("s-r/x-twitter-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 = {
    "targets": [
        "nasa",
        "openai",
    ],
    "search_terms": ["web scraping"],
    "tweets_per_term": 30,
    "search_pages": 2,
    "tweets_per_user": 40,
    "max_targets": 50,
    "concurrency": 3,
    "retries": 3,
}

# Run the Actor and wait for it to finish
run = client.actor("s-r/x-twitter-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 '{
  "targets": [
    "nasa",
    "openai"
  ],
  "search_terms": [
    "web scraping"
  ],
  "tweets_per_term": 30,
  "search_pages": 2,
  "tweets_per_user": 40,
  "max_targets": 50,
  "concurrency": 3,
  "retries": 3
}' |
apify call s-r/x-twitter-scraper --silent --output-dataset

```

## MCP server setup

```json
{
    "mcpServers": {
        "apify": {
            "type": "http",
            "url": "https://mcp.apify.com/?tools=fetch-actor-details,s-r/x-twitter-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/7LmWw30N0fpDnDuzt/builds/OuEvqOHYEnE7HU0AP/openapi.json
