X (Twitter) posts, threads, profiles, search and media — as typed dataclasses, or Markdown you can paste straight into a model.
No signup · no OAuth · no bearer token · sync and async
Website · API reference · SDK guide · Examples · Node SDK · MCP server
Contents
- Why this SDK
- Install
- How it works
- Quick start
- Recipes
- What's new in 1.2.0
- API — options · methods · client state · errors
- Rate limits
- Examples
- Tests
- Related repositories
Why this SDK
| 🔑 No API key | No signup, no OAuth, no bearer token. Xcrap() and you are reading. |
| ⚡ Sync and async | Xcrap and AsyncXcrap, same surface, both context managers. |
| 🧩 Typed | Dataclasses mirroring the API's shapes, full hints, py.typed. |
| 🪶 One dependency | httpx. |
| 📝 Markdown-first | markdown=True on every read method, with a token estimate in client.last_meta.markdown_tokens. |
| 🧠 Optional response cache | Xcrap(cache_ttl=60) keeps answers in memory, so repeats cost no request. |
| 🚦 Rate-limit aware | client.rate_limit after every call, and a typed XcrapRateLimited with retry_after. |
Install
pip install xcrap-sdk
Other package managers
uv add xcrap-sdk
poetry add xcrap-sdk
pipenv install xcrap-sdk
from xcrap import Xcrap, AsyncXcrap
xcrap = Xcrap() # talks to https://xcrap.cc
Both clients are context managers, which is the tidiest way to close the connection pool:
with Xcrap() as xcrap:
...
async with AsyncXcrap() as xcrap:
...
How it works
sequenceDiagram
autonumber
participant App as Your code
participant SDK as xcrap (Python)
participant API as XCrap API
participant X as X (public posts)
App->>SDK: xcrap.thread(url, max_tweets=50)
alt cache_ttl set and answer still fresh
SDK-->>App: served from memory (no request)
else
SDK->>API: GET /v1/thread?url=…
API->>X: read the public thread
X-->>API: posts
API-->>SDK: JSON + x-ratelimit-* headers
SDK-->>App: Thread dataclass, client.rate_limit updated
end
Pick the shape you need with format= — JSON comes back as dataclasses, everything else as a str:
flowchart LR
link["🔗 x.com link, id or handle"] --> api(["XCrap API"])
api --> json["JSON<br/><sub>dataclasses</sub>"]
api --> md["Markdown<br/><sub>for LLM prompts</sub>"]
api --> yaml["YAML"]
api --> csv["CSV<br/><sub>pandas, sheets</sub>"]
api --> html["HTML<br/><sub>embeds</sub>"]
classDef accent fill:#f62d00,stroke:#141312,color:#ffffff
class api accent
Quick start
from xcrap import Xcrap
with Xcrap() as xcrap:
tweet = xcrap.tweet("https://x.com/jack/status/20")
print(tweet.text) # just setting up my twttr
print(tweet.author.screen_name) # jack
print(tweet.metrics.likes) # 308067
A bare id works too: xcrap.tweet("20").
Recipes
📝 Feed a post to an LLM as Markdown
with Xcrap() as xcrap:
markdown = xcrap.tweet("https://x.com/jack/status/20", markdown=True)
print(markdown)
print(f"~{xcrap.last_meta.markdown_tokens} tokens") # budget your context window
Every read method takes markdown=True, or format="json" | "markdown" | "yaml" | "csv" | "html". JSON comes back as dataclasses; every other format comes back as a str.
🧵 Unroll a thread
with Xcrap() as xcrap:
thread = xcrap.thread(
"https://x.com/naval/status/1002103360646823936",
max_tweets=50,
)
print(f"@{thread.author.screen_name} — {thread.count} posts")
for index, post in enumerate(thread.tweets, start=1):
print(f"{index}. {post.text}")
if thread.truncated:
print("…thread was longer than max_tweets")
📜 Page an account's timeline
with Xcrap() as xcrap:
# One page at a time, cursor in hand:
page = xcrap.user_tweets("nasa", count=20, exclude_replies=True)
while page.next_cursor:
for post in page.tweets:
print(post.created_at, post.text[:60])
page = xcrap.user_tweets("nasa", count=20, cursor=page.next_cursor)
# Or let the SDK walk the pages for you:
for post in xcrap.iter_user_tweets("nasa", count=20, limit=100):
print(post.id, post.text[:60])
🗂️ Export an account's history (with reposts)
with Xcrap() as xcrap:
history = xcrap.user_history(
"naval",
max_posts=500,
since="2025-01-01",
include_reposts=True,
)
print(f"{history.count} posts")
print("stopped because:", history.stop_reason) # max_posts | window | end_of_timeline | page_limit | upstream_error
🔎 Search with X's own operators
with Xcrap() as xcrap:
result = xcrap.search("from:nasa mars", feed="top", since="2025-01-01")
for post in result.tweets:
print(post.text)
if result.next_cursor:
more = xcrap.search("from:nasa mars", feed="top", cursor=result.next_cursor)
feed is latest (default), top, photos or videos.
🖼️ Download media
with Xcrap() as xcrap:
url = "https://x.com/NASA/status/1793412932641689801"
listing = xcrap.media(url)
print(f"{listing.count} attachment(s)")
for item in listing.media:
file = xcrap.download_media(url, index=item.index, quality="best")
saved = file.save() # uses the suggested filename
print(f"saved {saved} ({file.content_type}, {file.content_length} bytes)")
📦 Resolve fifty posts in one call
with Xcrap() as xcrap:
result = xcrap.bulk([
"https://x.com/jack/status/20",
"https://x.com/nasa/status/1",
"1849565662058590299",
])
print(f"{result.succeeded} ok, {result.failed} failed")
for item in result.results:
if item.ok:
print("✓", item.tweet.text)
else:
print("✗", item.input, item.error["message"])
Bulk takes at most 50 URLs and never fails the whole batch for one bad link.
⚡ Async, with concurrency
import asyncio
from xcrap import AsyncXcrap
async def main() -> None:
async with AsyncXcrap() as xcrap:
handles = ["nasa", "jack", "esa"]
users = await asyncio.gather(*(xcrap.user(h) for h in handles))
for user in users:
print(f"@{user.screen_name}: {user.metrics.followers:,} followers")
async for post in xcrap.iter_user_tweets("nasa", limit=50):
print(post.text[:60])
asyncio.run(main())
🚨 Handle errors by type
from xcrap import (
Xcrap,
XcrapBadRequest,
XcrapNotFound,
XcrapOptedOut,
XcrapRateLimited,
XcrapUpstreamError,
)
import time
with Xcrap() as xcrap:
try:
tweet = xcrap.tweet("https://x.com/jack/status/20")
except XcrapNotFound as error:
print("Gone for good:", error)
except XcrapRateLimited as error:
print(f"Budget spent, sleeping {error.retry_after}s")
time.sleep(error.retry_after)
except XcrapOptedOut:
print("Account opted out — do not retry")
except XcrapUpstreamError:
print("Transient, retry in a minute")
except XcrapBadRequest as error:
print("Fix the input:", error.hint)
Every message ends with the action that resolves it, so it is safe to surface directly to a user or an agent.
🚦 Watch your rate-limit budget
with Xcrap() as xcrap:
xcrap.trends(count=10)
limit = xcrap.rate_limit
print(f"{limit.remaining}/{limit.limit} left, window resets at {limit.reset_at:%H:%M:%S}")
What's new in 1.2.0
| 🧠 In-memory response cache | Xcrap(cache_ttl=60) keeps successful GET responses for that many seconds. A repeat costs no request and no rate limit. fresh=True always skips it; clear_cache() empties it. |
🔁 include_reposts |
user_history(handle, include_reposts=True) also returns the posts the account reposted. |
🛑 stop_reason |
History responses say why the walk stopped: max_posts, window, end_of_timeline, page_limit or upstream_error (the posts collected until then are still returned). |
with Xcrap(cache_ttl=60) as xcrap: # one minute
xcrap.user("nasa") # one request
xcrap.user("nasa") # answered from memory
xcrap.clear_cache()
API
Xcrap(base_url=..., **options) / AsyncXcrap(base_url=..., **options)
| option | default | meaning |
|---|---|---|
base_url |
https://xcrap.cc |
API origin. Leave it; change it only to route through a proxy you control |
timeout |
30.0 |
Per-request timeout, seconds |
retries |
1 |
Retries on 502/503/504 and transport errors |
retry_delay |
0.5 |
Backoff before a retry, seconds |
cache_ttl |
0.0 |
Keep successful GET responses in memory this many seconds; 0 is off |
user_agent |
xcrap-python-sdk/… |
Replaces the default descriptive agent |
headers |
{} |
Extra headers on every request |
client |
None |
Bring your own httpx.Client / AsyncClient |
Methods
| method | endpoint | returns |
|---|---|---|
tweet(url, ...) |
/v1/tweet |
Tweet |
thread(url, max_tweets=25, ...) |
/v1/thread |
Thread |
user(handle, ...) |
/v1/user |
User |
user_tweets(handle, count=20, cursor=None, exclude_replies=True, media_only=False, ...) |
/v1/user/tweets |
Timeline |
iter_user_tweets(handle, limit=None, ...) |
/v1/user/tweets |
iterator of Tweet |
user_history(handle, max_posts=200, since=None, until=None, include_replies=False, include_reposts=False, ...) |
/v1/user/history |
AccountHistory |
search(query, feed="latest", since=None, until=None, cursor=None, ...) |
/v1/search |
SearchResult |
replies(url, sort="top", ...) |
/v1/replies |
Replies |
followers(handle, cursor=None, ...) |
/v1/user/followers |
UserList |
following(handle, cursor=None, ...) |
/v1/user/following |
UserList |
trends(count=20, ...) |
/v1/trends |
TrendsResult |
media(url, ...) |
/v1/media |
MediaList |
download_media(url, index=0, quality="best") |
/v1/media/download |
DownloadedMedia |
bulk(urls, ...) |
/v1/bulk |
BulkResult (≤ 50 urls) |
clear_cache() |
— | empties the in-memory response cache |
Every read method accepts markdown=, format= and fresh=. fresh=True bypasses both the server's five-day cache and the client cache. AsyncXcrap has the same methods as coroutines, and iter_user_tweets becomes an async iterator.
Client state
client.rate_limit—RateLimit(limit, remaining, reset, reset_at)from the last response.client.last_meta—ResponseMeta(format, cache status,markdown_tokens) from the last response.
Errors
| class | status | when |
|---|---|---|
XcrapBadRequest |
400 | missing or unparseable parameter |
XcrapNotFound |
404 | deleted, suspended, private or nonexistent |
XcrapRateLimited |
429 | budget spent — carries retry_after, reset_at, budget |
XcrapOptedOut |
451 | the account opted out of XCrap |
XcrapUpstreamError |
5xx | X could not be reached or timed out; usually transient |
XcrapConnectionError |
— | DNS, TLS, socket or timeout failure |
XcrapError |
— | base class for all of the above |
flowchart TD
E{{"XcrapError"}} --> B["XcrapBadRequest · 400<br/><sub>fix the input</sub>"]
E --> N["XcrapNotFound · 404<br/><sub>do not retry</sub>"]
E --> R["XcrapRateLimited · 429<br/><sub>sleep retry_after</sub>"]
E --> O["XcrapOptedOut · 451<br/><sub>final</sub>"]
E --> U["XcrapUpstreamError · 5xx<br/><sub>retry shortly</sub>"]
E --> C["XcrapConnectionError<br/><sub>check the network</sub>"]
classDef accent fill:#f62d00,stroke:#141312,color:#ffffff
class E accent
Rate limits
Budgets are per endpoint, per IP, and every response carries x-ratelimit-limit / -remaining / -reset.
| endpoint | method(s) | budget |
|---|---|---|
/v1/tweet |
tweet |
45 / minute |
/v1/user |
user |
45 / minute |
/v1/thread |
thread |
15 / minute |
/v1/user/tweets |
user_tweets, iter_user_tweets |
15 / minute |
/v1/user/followers, /v1/user/following |
followers, following |
15 / minute |
/v1/replies |
replies |
15 / minute |
/v1/user/history |
user_history |
4 / 5 minutes |
/v1/search |
search |
10 / 15 minutes |
/v1/bulk |
bulk |
6 / 5 minutes (up to 50 posts each) |
/v1/media, /v1/media/download |
media, download_media |
20 / minute |
/v1/trends |
trends |
90 / minute |
Examples
Small, runnable projects live in examples/. Each works as soon as you install it — there is no key to set up.
| Example | What it does | Extra dependency |
|---|---|---|
💻 cli/ |
xcrap-cli tweet, thread, user, search and history from your terminal, saving to CSV, Markdown or JSON |
none (argparse) |
🎮 discord-bot/ |
Turns X links into clean embeds and sends !thread as a Markdown file |
discord.py 2.x |
✈️ telegram-bot/ |
/tweet, /thread and automatic link expansion |
python-telegram-bot v21 |
🗄️ thread-archiver/ |
Saves a list of threads to archive/<handle>-<id>.md, waiting out rate limits |
none |
🤖 ai-summary/ |
Builds a ready-to-send summary prompt from a thread, for any LLM | none |
cd examples/cli
python -m venv .venv && source .venv/bin/activate
pip install -r requirements.txt
python xcrap_cli.py tweet https://x.com/jack/status/20
Stop a running bot with Ctrl + C.
Tests
pip install -e ".[test]"
pytest -v
Most of the suite is an integration smoke test: it talks to the live API, because that is the only thing that proves the client and the API still agree.
Related repositories
| Repository | What it is |
|---|---|
| 🟩 xcrap-node | The Node.js SDK — npm install @xcrapcc/sdk |
| 🤖 Xcrap-mcp | The MCP server for Claude, Cursor and other agents — npx -y @xcrapcc/mcp |
| 📖 xcrap-docs | Guides, API reference, OpenAPI 3.1, llms.txt and skills.md |
| 🏠 XcrapCC | Everything XCrap on GitHub |
xcrap.cc · Docs · OpenAPI · llms.txt · skills.md
MIT licensed · Public data only · Not affiliated with X Corp.
Metadata
Release files for xcrap-sdk 1.2.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| xcrap_sdk-1.2.0.tar.gz | 26.4 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| xcrap_sdk-1.2.0-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 52.7 kB
Release files / xcrap_sdk-1.2.0.tar.gz
| Download URL | xcrap_sdk-1.2.0.tar.gz |
|---|---|
| Size | 26.4 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
9ede4dbdd1256035832290038b7f679afc53172ffe0771b7b27c07a2f20387ea
|
|
BLAKE2b-256 checksum How to use checksums |
d0bbac827c69e841ece4f904b844c951e5f016d242f7aac6e706abb268b00483
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/7.0.0 CPython/3.12.3
|
Release files / xcrap_sdk-1.2.0-py3-none-any.whl
| Download URL | xcrap_sdk-1.2.0-py3-none-any.whl |
|---|---|
| Size | 26.3 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
976420e0722dd5506dd5fa32a337a4ac2e98a550aea487588d48b2f5840bcb47
|
|
BLAKE2b-256 checksum How to use checksums |
34c480c80af78a865dd779465832e04eccc335013314a1d70617cd841b3c497b
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/7.0.0 CPython/3.12.3
|