<?xml version="1.0" encoding="utf-8"?>
<rss version="2.0">
<channel>
<title>Coder Legion - Recent posts tagged twitter</title>
<link>https://coderlegion.com/tag/twitter</link>
<description>Powered by Question2Answer</description>
<item>
<title>Twitter (X) Scraper in Python: Tweet Metrics, SQLite Snapshots, and the Walls You'll Hit (2026)</title>
<link>https://coderlegion.com/31035/twitter-x-scraper-in-python-tweet-metrics-sqlite-snapshots-and-the-walls-youll-hit-2026</link>
<description>&lt;p&gt;Most people who search for a &quot;Twitter scraper&quot; don't need the whole firehose. They have a list of tweet URLs (a launch, a campaign, posts from a few creators) and want to know how many likes and replies each one has, and how that changes over a week. That job is still doable in plain Python without logging in. Pulling a user's whole timeline is a different story, and I'll be honest about where it breaks.&lt;/p&gt;
&lt;p&gt;This guide builds a small tracker: tweet URLs go in, and a SQLite table of engagement snapshots comes out.&lt;/p&gt;
&lt;h3 id=&quot;what-still-works-without-logging-in&quot;&gt;What still works without logging in&lt;/h3&gt;
&lt;p&gt;When a site embeds a tweet, the embed widget loads its data from a public JSON endpoint:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;https://cdn.syndication.twimg.com/tweet-result?id=&amp;lt;tweet_id&amp;gt;&amp;amp;token=&amp;lt;token&amp;gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;It returns the tweet text, creation date, author (name, handle, verified flags), media, &lt;code&gt;favorite_count&lt;/code&gt; (likes) and &lt;code&gt;conversation_count&lt;/code&gt; (replies). There's no API key and no cookie. The &lt;code&gt;token&lt;/code&gt; is derived from the tweet ID with a small formula from the open-source embed code. Today the endpoint seems to accept any non-empty token, but an empty one returns &lt;code&gt;{}&lt;/code&gt;, so compute it the same way the widget does in case that changes.&lt;/p&gt;
&lt;p&gt;What you &lt;em&gt;don't&lt;/em&gt; get matters just as much: no retweet count, no quote count, no view count, and no way to list a user's tweets. Keep that in mind before you build a dashboard on it.&lt;/p&gt;
&lt;h3 id=&quot;setup&quot;&gt;Setup&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;python -m venv .venv &amp;amp;&amp;amp; source .venv/bin/activate
pip install requests
&lt;/code&gt;&lt;/pre&gt;
&lt;h3 id=&quot;step-1-turn-urls-into-ids-and-ids-into-dates-&quot;&gt;Step 1: Turn URLs into IDs (and IDs into dates)&lt;/h3&gt;
&lt;p&gt;Tweet IDs are &quot;snowflakes&quot;: the top bits are a timestamp in milliseconds since X's custom epoch. That means you can get the post time from the ID alone, which is handy for sorting before you've fetched anything. This only holds for tweets from November 2010 onward, when snowflake IDs started. Older IDs are small sequential numbers, so prefer the &lt;code&gt;created_at&lt;/code&gt; field once you have the tweet.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;import math
import re
from datetime import datetime, timezone

ID_RE = re.compile(r&quot;(?:twitter|x)\.com/[^/]+/status(?:es)?/(\d+)&quot;)
TWITTER_EPOCH_MS = 1288834974657


def tweet_id(url_or_id: str) -&amp;gt; str:
    if url_or_id.isdigit():
        return url_or_id
    m = ID_RE.search(url_or_id)
    if not m:
        raise ValueError(f&quot;not a tweet URL: {url_or_id}&quot;)
    return m.group(1)


def posted_at(tid: str) -&amp;gt; datetime:
    ms = (int(tid) &amp;gt;&amp;gt; 22) + TWITTER_EPOCH_MS
    return datetime.fromtimestamp(ms / 1000, tz=timezone.utc)
&lt;/code&gt;&lt;/pre&gt;
&lt;h3 id=&quot;step-2-compute-the-embed-token&quot;&gt;Step 2: Compute the embed token&lt;/h3&gt;
&lt;p&gt;The embed code does &lt;code&gt;((id / 1e15) * Math.PI).toString(36)&lt;/code&gt; and then strips every &lt;code&gt;0&lt;/code&gt; and the dot. Python has no float-to-base-36, so here's a short port:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;DIGITS = &quot;0123456789abcdefghijklmnopqrstuvwxyz&quot;


def _to_base36(x: float, frac_digits: int = 12) -&amp;gt; str:
    whole, frac = int(x), x - int(x)
    out = &quot;&quot;
    while whole:
        out = DIGITS[whole % 36] + out
        whole //= 36
    out = (out or &quot;0&quot;) + &quot;.&quot;
    for _ in range(frac_digits):
        frac *= 36
        d = int(frac)
        out += DIGITS[d]
        frac -= d
    return out


def embed_token(tid: str) -&amp;gt; str:
    return re.sub(r&quot;0+|\.&quot;, &quot;&quot;, _to_base36((int(tid) / 1e15) * math.pi))
&lt;/code&gt;&lt;/pre&gt;
&lt;h3 id=&quot;step-3-fetch-one-tweet-politely&quot;&gt;Step 3: Fetch one tweet, politely&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;import random
import time
import requests

SESSION = requests.Session()
SESSION.headers[&quot;User-Agent&quot;] = (
    &quot;Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 &quot;
    &quot;(KHTML, like Gecko) Chrome/129.0 Safari/537.36&quot;
)
ENDPOINT = &quot;https://cdn.syndication.twimg.com/tweet-result&quot;


def fetch_tweet(tid: str, retries: int = 4) -&amp;gt; dict | None:
    for attempt in range(retries):
        r = SESSION.get(
            ENDPOINT,
            params={&quot;id&quot;: tid, &quot;token&quot;: embed_token(tid), &quot;lang&quot;: &quot;en&quot;},
            timeout=20,
        )
        if r.status_code == 404:
            return None  # deleted, protected, or never existed
        if r.status_code == 429 or r.status_code &amp;gt;= 500:
            time.sleep(2 ** attempt + random.random())
            continue
        r.raise_for_status()
        data = r.json()
        if not data or data.get(&quot;__typename&quot;) != &quot;Tweet&quot;:
            return None  # empty object or a tombstone (withheld, age-gated...)
        return data
    raise RuntimeError(f&quot;gave up on {tid} after {retries} tries&quot;)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Three things to notice. A 404 is normal and not an error: tweets get deleted and accounts go private. An empty &lt;code&gt;{}&lt;/code&gt; or a non-&lt;code&gt;Tweet&lt;/code&gt; type also means &quot;nothing usable&quot;. And retry only on 429 and 5xx, with backoff and jitter.&lt;/p&gt;
&lt;h3 id=&quot;step-4-store-snapshots-in-sqlite&quot;&gt;Step 4: Store snapshots in SQLite&lt;/h3&gt;
&lt;p&gt;One row per tweet per run. Never overwrite, because the history is the useful part.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;import sqlite3

db = sqlite3.connect(&quot;tweets.db&quot;)
db.executescript(&quot;&quot;&quot;
CREATE TABLE IF NOT EXISTS tweets (
    id TEXT PRIMARY KEY, handle TEXT, posted_at TEXT, text TEXT
);
CREATE TABLE IF NOT EXISTS snapshots (
    id TEXT, fetched_at TEXT, likes INTEGER, replies INTEGER,
    PRIMARY KEY (id, fetched_at)
);
&quot;&quot;&quot;)


def track(urls: list[str]) -&amp;gt; None:
    now = datetime.now(timezone.utc).isoformat(timespec=&quot;seconds&quot;)
    for url in urls:
        tid = tweet_id(url)
        t = fetch_tweet(tid)
        if t is None:
            print(f&quot;skip {tid}: unavailable&quot;)
            continue
        db.execute(
            &quot;INSERT OR IGNORE INTO tweets VALUES (?, ?, ?, ?)&quot;,
            (tid, t[&quot;user&quot;][&quot;screen_name&quot;], t.get(&quot;created_at&quot;) or posted_at(tid).isoformat(), t[&quot;text&quot;]),
        )
        db.execute(
            &quot;INSERT OR REPLACE INTO snapshots VALUES (?, ?, ?, ?)&quot;,
            (tid, now, t.get(&quot;favorite_count&quot;, 0), t.get(&quot;conversation_count&quot;, 0)),
        )
        time.sleep(1.5 + random.random())  # stay well under any rate limit
    db.commit()


if __name__ == &quot;__main__&quot;:
    track(open(&quot;tweets.txt&quot;).read().split())
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Put one URL per line in &lt;code&gt;tweets.txt&lt;/code&gt; and run it from cron every few hours.&lt;/p&gt;
&lt;h3 id=&quot;step-5-see-what-moved&quot;&gt;Step 5: See what moved&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-sql&quot;&gt;SELECT t.handle, s.id,
       MAX(s.likes) - MIN(s.likes)     AS likes_gained,
       MAX(s.replies) - MIN(s.replies) AS replies_gained,
       COUNT(*)                        AS snapshots
FROM snapshots s JOIN tweets t USING (id)
WHERE s.fetched_at &amp;gt;= datetime('now', '-7 days')
GROUP BY s.id
ORDER BY likes_gained DESC;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;For tracking a few hundred known tweets, that's the whole thing, and it has been stable for me.&lt;/p&gt;
&lt;h3 id=&quot;the-walls-you-ll-hit&quot;&gt;The walls you'll hit&lt;/h3&gt;
&lt;p&gt;&lt;strong&gt;No timelines.&lt;/strong&gt; The tracker only works when you already know the tweet IDs. The embed timeline endpoint (&lt;code&gt;syndication.twitter.com/srv/timeline-profile/...&lt;/code&gt;) answered my very first request with &lt;code&gt;429 Rate limit exceeded&lt;/code&gt;, and logged-out &lt;code&gt;x.com&lt;/code&gt; profile pages render almost nothing useful in raw HTML.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Missing metrics.&lt;/strong&gt; No retweets, quotes, or views here. If those numbers matter, this endpoint isn't enough.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Guest tokens and GraphQL.&lt;/strong&gt; The web app gets data from internal GraphQL operations (&lt;code&gt;UserByScreenName&lt;/code&gt;, &lt;code&gt;UserTweets&lt;/code&gt; and so on). You can get a guest token without logging in, but the operation IDs and required &lt;code&gt;features&lt;/code&gt; flags change with each web release, and X keeps narrowing what guests can see. Expect to fix that code often.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Logged-in cookies.&lt;/strong&gt; Scraping with your own session cookies works until the account gets rate-limited, challenged, or locked. Don't do it with an account you care about, and read X's terms before you scale anything.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;The official API.&lt;/strong&gt; It's the clean route, but the free tier is very limited for reads and the paid tiers are priced for companies. Check the current plans on X's developer portal before you plan around it.&lt;/p&gt;
&lt;h3 id=&quot;when-i-stop-doing-it-myself&quot;&gt;When I stop doing it myself&lt;/h3&gt;
&lt;p&gt;If the job grows from &quot;these 200 tweets&quot; into &quot;every new post from these 50 accounts, with views and retweets&quot;, the guest-token treadmill eats more time than it saves. That's where I use an API. For example, &lt;a rel=&quot;nofollow&quot; href=&quot;https://scrapeatlas.com&quot;&gt;ScrapeAtlas*&lt;/a&gt; has &lt;code&gt;GET /v1/twitter/user-tweets?handle=&amp;lt;handle&amp;gt;&lt;/code&gt; (newest first, with optional &lt;code&gt;since&lt;/code&gt;/&lt;code&gt;until&lt;/code&gt;) and &lt;code&gt;GET /v1/twitter/profile?handle=&amp;lt;handle&amp;gt;&lt;/code&gt;. Each tweet comes back with &lt;code&gt;legacy.favorite_count&lt;/code&gt;, &lt;code&gt;retweet_count&lt;/code&gt;, &lt;code&gt;reply_count&lt;/code&gt;, &lt;code&gt;quote_count&lt;/code&gt;, &lt;code&gt;views.count&lt;/code&gt; and its URL:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;import os
import requests

r = requests.get(
    &quot;https://api.scrapeatlas.com/v1/twitter/user-tweets&quot;,
    params={&quot;handle&quot;: &quot;python_tip&quot;, &quot;since&quot;: &quot;2026-10-01T00:00:00Z&quot;},
    headers={&quot;x-api-key&quot;: os.environ[&quot;SCRAPEATLAS_API_KEY&quot;]},
    timeout=60,
)
r.raise_for_status()
body = r.json()
if not body[&quot;coverage&quot;][&quot;complete&quot;]:
    print(&quot;partial result:&quot;, body[&quot;coverage&quot;][&quot;warnings&quot;])
for tw in body[&quot;tweets&quot;]:
    lg = tw[&quot;legacy&quot;]
    print(tw[&quot;url&quot;], lg[&quot;favorite_count&quot;], lg[&quot;retweet_count&quot;], tw.get(&quot;views&quot;, {}).get(&quot;count&quot;))
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Check &lt;code&gt;coverage.complete&lt;/code&gt; and &lt;code&gt;warnings&lt;/code&gt; every time. A partial page is labelled as one, and you should treat it that way rather than as &quot;this account posted less&quot;.&lt;/p&gt;
&lt;p&gt;Whichever route you take, keep the snapshot table. The numbers that matter are the changes over time, not one reading. If you've found a more stable logged-out way to list a user's recent tweets, I'd like to hear it in the comments.&lt;/p&gt;
&lt;p&gt;*I build it.&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/31035/twitter-x-scraper-in-python-tweet-metrics-sqlite-snapshots-and-the-walls-youll-hit-2026</guid>
<pubDate>Wed, 14 Oct 2026 09:11:05 +0000</pubDate>
</item>
</channel>
</rss>