# snscrape fails on Twitter with blocked (404). Is there a working replacement?

> snscrape's Twitter scrapers stopped working in 2023. How to rebuild a twitter-search or twitter-user pipeline on an X API, with field mapping, costs and limits.

Canonical page: https://okdata.online/answers/snscrape-twitter-blocked-404/

**Short answer: **snscrape's Twitter modules relied on logged-out access that X closed, and there is no fix in the project. Rebuild the pipeline on X post search ($0.006 per page of about 20 posts) and X account posts ($0.006 per page). A 10,000-post research corpus costs about $3.00.

If your thesis code still calls `snscrape twitter-search`, it has probably been printing blocked (404) since 2023. The Twitter scrapers in snscrape read pages that X used to show to logged-out visitors. X stopped doing that, and the issue on GitHub has stayed open without a fix. The rest of your pipeline can survive: swap the collection step for an API that returns the same kind of records, and keep your cleaning and analysis code.

## The replacement, command by command

| snscrape | Use instead |
|---|---|
| `twitter-search "query"` | [X post search](/apis/x/posts/search/), same query syntax |
| `twitter-user handle` | [X account posts](/apis/x/users/posts/list/), newest first |
| user profile fields | [X account profile](/apis/x/users/get/) |

Your old field names map like this: `content` is `text`, `date` is `published_at`, `likeCount` is `metrics.likes`, `retweetCount` is `metrics.reposts`, `replyCount` is `metrics.comments` and `viewCount` is `metrics.views`. Quote counts sit in `ext.quotes` when you ask for `view=full`.

## Writing JSONL the way snscrape did

```python
import json, os, requests

API = "https://api.okdata.online/v1"
H = {"Authorization": f"Bearer {os.environ['OKDATA_API_KEY']}"}

def pages(path, params, limit):
    cursor = None
    for _ in range(limit):
        body = requests.get(f"{API}/{path}", headers=H, timeout=60,
                            params={**params, **({"cursor": cursor} if cursor else {})}).json()
        if body["code"] != "ok":
            raise RuntimeError(f"{body['code']}: {body['message']}")
        yield from body["data"]["items"]
        if not body["page"]["has_more"]:
            return
        cursor = body["page"]["next_cursor"]

query = "climate policy lang:en since:2026-10-01 until:2026-11-01"
with open("tweets.jsonl", "w", encoding="utf-8") as out:
    for post in pages("x/posts/search", {"query": query, "sort": "latest", "view": "full"}, limit=50):
        out.write(json.dumps(post, ensure_ascii=False) + "\n")
```

## Budget

| Corpus | Pages | Cost |
|---|---|---|
| 1,000 posts | 50 | $0.30 |
| 10,000 posts | 500 | $3.00 |
| 50 accounts, 5 pages each | 250 | $1.50 |

Every page is one charge. Failed calls are free, so a crash halfway through only costs the pages you actually received.

## Limits worth stating in your methods section

- You get what X search returns to the public for that query. It is neither a random sample nor a complete census.
- Protected accounts and deleted posts are out of reach, as they were with snscrape.
- Follower networks and reply trees are not available.

## Other routes

X's official full-archive search goes back to 2006 and is open to pay-per-use accounts at $0.005 per post read. It is the cleaner choice when a reviewer will ask exactly how the data was sampled. snscrape itself still covers some other sites. Whichever route you take, keep the exact queries and collection dates with the data. More on running collections like this is in the [trend research guide](/solutions/trend-research/).

## Endpoints used

- [X post search](https://okdata.online/apis/x/posts/search/) `x.posts.search`: $0.006 per call · about 20 posts per page
- [X account posts](https://okdata.online/apis/x/users/posts/list/) `x.users.posts.list`: $0.006 per call · about 20 posts per page
- [X account profile](https://okdata.online/apis/x/users/get/) `x.users.get`: $0.001 per call

Updated 2026-11-12
