This Python example provides twitter analytics for one public X account. It exports post metrics and a short engagement report. This page shows a stored sample; there is no hosted demo.
The problem
A timeline mixes original posts, replies, quotes and reposts. Analysts need a consistent way to compare engagement across those types and see which posts contributed to the result. A follower count alone does not explain how recent posts performed.
What the app does
- Retrieves recent posts for one public account, with a configurable page limit.
- Writes per-post metrics, engagement rates and exclusion reasons to CSV.
- Summarizes medians, means, posting frequency, post types and media.
- Lists top posts and compares weekday and hour slots in a chosen time zone.
- Saves raw responses so the report can be rebuilt offline.
How it works
- Retrieve posts. The SDK calls
GET /twitter/user/postswithusernameand followsnext_cursor. The default is three pages;--pagesaccepts 1 to 10. There is no page size setting. - Prepare rows. Deduplicate posts by id, sort by date and classify post type and media. Preserve returned and repeated counts in a page log.
- Calculate rates. Engagements sum likes, reposts, replies, quotes and bookmarks. Divide by views or followers and multiply by 100. Follower rates use the follower count at run time.
- Apply exclusions. Leave reposts out of rates. Posts without views can still contribute to the per-follower rate. The summary lists exclusions.
- Export. Write a post CSV, summary CSV and markdown report. Weekday and hour comparisons use median engagements and prefer slots with at least two posts. Treat small samples as hints.
The report covers retrieved posts, not full account history. It does not establish follower growth or complete thread coverage.
The code
These are verbatim excerpts from x_account_analytics.py. The key comes only from a private key file, not environment variables. Imports and now() are defined in the full script.
def read_key(path):
p = os.path.expanduser(path)
if not os.path.isfile(p):
raise SystemExit(f"key file not found: {p} (put your Desearch API key in it, one line)")
with open(p, encoding="utf-8") as f:
k = f.read().strip()
if not k:
raise SystemExit(f"key file is empty: {p}")
return k
The fetch function follows cursors and records per-page metadata:
async def fetch(username, pages, key):
import aiohttp
from desearch_py import Desearch
raw = {"started": now(), "username": username, "pages_requested": pages, "calls": []}
cursor, seen_cursors = None, set()
async with Desearch(api_key=key) as client:
for page in range(1, pages + 1):
at, t0 = now(), time.time()
rec = {"page": page, "cursor_sent": cursor, "at": at}
try:
res = await client.x_user_posts(username, cursor=cursor, include_metadata=True)
body = res.data.model_dump(mode="json", exclude_unset=True)
meta = res.metadata
rec.update(status=200, cost_usd=meta.cost_usd, usage_count=meta.usage_count, service=meta.service,
body_cost_usd=body.get("cost_usd"), body_usage_count=body.get("usage_count"), body=body)
except aiohttp.ClientResponseError as e:
h = e.headers or {}
rec.update(status=e.status, error=e.message, cost_usd=h.get("X-Desearch-Cost-Usd"),
usage_count=h.get("X-Desearch-Usage-Count"), body=None)
except Exception as e: # network or validation error: record it and stop
rec.update(status=None, error=repr(e)[:500], body=None)
rec["seconds"] = round(time.time() - t0, 2)
raw["calls"].append(rec)
n = len((rec.get("body") or {}).get("tweets") or [])
print(f"{at} page {page} status={rec['status']} {rec['seconds']}s posts={n} cost={rec.get('cost_usd')}",
file=sys.stderr, flush=True)
if rec["status"] != 200 or not n:
break
cursor = rec["body"].get("next_cursor")
if not cursor or cursor in seen_cursors:
break
seen_cursors.add(cursor)
return raw
Endpoints
GET /twitter/user/posts
Example output
This table is copied from samples/sample-summary.md, rebuilt from the stored 2026-10-19 sample. It covers 18 posts. The public sample omits posts containing outside links or other product names, so its results differ from the full live run.
| metric | median | mean |
|---|---|---|
| engagements per post | 3.5 | 4.278 |
| ER per view % (n=18) | 4.669 | 4.576 |
| ER per follower % | 0.202 | 0.246 |
The full live run had 43 unique posts from 44 returned posts. The table above describes only the smaller stored sample.
Run it yourself
Get the example folder and install its requirements from that directory:
pip install -r requirements.txtRebuild the stored sample without a key or API calls using the README command:
python x_account_analytics.py --from-raw samples/sample-raw.json --out output/sampleFor live collection, create a key at the console and save it in a private file. Run x_account_analytics.py with --username set to the public account and --key-file pointing to that file. The default key path is ~/.desearch_api_key. Use --pages for the page limit, --tz for an IANA time zone and --out for the output prefix.
What it costs
The live three-page run on 2026-10-19 cost $0.0066 for 44 returned posts, measured from response cost metadata. It produced 43 unique posts after deduplication. The pages took 2.44, 2.42 and 1.79 seconds respectively. These are observations from that run, not a promised runtime. Rebuilding the stored sample makes no API calls. See pricing.
Ideas to extend it
- Save dated snapshots to compare the same account over time.
- Compare post types across accounts using the same retrieved window.
- Add a review column for analysts to annotate top posts.
