Scraping Infrastructure
Everything that keeps a no-API X client working: query-ID discovery, request signing, browser identity, a multi-account pool with resumable scrapes, plus the standalone proxy, stealth-browser and pagination building blocks.
Overview
Two groups of things live here, and it is worth knowing which is which.
On the live request path. These run on every call the CLI, the MCP server and the library make, with no configuration from you:
- GraphQL query-ID discovery reads x.com's own bundles so a rotated ID does not break a scrape.
- Request signing attaches the
x-client-transaction-idheader the web client sends. - Browser identity gives the HTTP client one consistent User-Agent and client-hint profile per process.
- The account pool and checkpoints spread work over several sessions and resume an interrupted scrape.
Building blocks for scrapers you write. These are exported and documented,
but nothing in the tree imports them, so changing their settings does not change
what xactions followers does:
- ProxyManager: proxy rotation with health tracking and auto-blacklisting.
- launchStealthBrowser: anti-detection Puppeteer wrapper with fingerprint randomization.
- PaginationEngine: scroll-based pagination with deduplication and checkpointing.
The one exception in that second group is DatasetStore (defined in
paginationEngine.js), which backs xactions dataset, the x_dataset_* MCP
tools, and /api/datasets.
Import paths
package.json publishes xactions/scrapers/twitter/http (the index) but not the
individual modules inside it, and does not publish src/scraping/ at all.
import ... from 'xactions/src/...' throws ERR_PACKAGE_PATH_NOT_EXPORTED.
Paths below that start with ./src/ are relative to the root of a clone; where a
published subpath exists, the example uses it.
Architecture
src/scraping/
├── proxyManager.js → Proxy rotation, health tracking, auto-blacklist
├── stealthBrowser.js → Anti-detection Puppeteer with fingerprint randomization
└── paginationEngine.js → Pagination, deduplication, checkpoints, retry, datasets
Data directories:
~/.xactions/datasets/: stored scraping datasets~/.xactions/scrape-checkpoints/:PaginationEnginecheckpoints, written bysaveCheckpoint()~/.xactions/checkpoints/: HTTP-scraper cursor checkpoints, written automatically bycreateCheckpoint()~/.xactions/query-ids.json: the discovered GraphQL query-ID cache~/.xactions/transaction-keys.json: the request-signing keys~/.xactions/accounts.db: the account pool
Quick Start
import { ProxyManager } from './src/scraping/proxyManager.js';
import { launchStealthBrowser, createStealthPage } from './src/scraping/stealthBrowser.js';
import { PaginationEngine, RetryPolicy } from './src/scraping/paginationEngine.js';
// 1. Set up proxies (optional)
const proxies = new ProxyManager(['http://proxy1:8080', 'http://proxy2:8080']);
// 2. Launch stealth browser
const proxy = proxies.getNext();
const browser = await launchStealthBrowser({ proxy, headless: true });
const page = await createStealthPage(browser, { proxy });
// 3. Scrape with pagination
const engine = new PaginationEngine({
maxPages: 50,
maxItems: 1000,
scrollDelay: 1500,
retries: 3,
deduplicateBy: 'id',
onProgress: (stats) => console.log(`${stats.total} items...`),
});
const result = await engine.scrapeWithPagination(page, async (page) => {
return page.evaluate(() => {
return [...document.querySelectorAll('[data-testid="tweet"]')].map(el => ({
id: el.querySelector('a[href*="/status/"]')?.href,
text: el.querySelector('[data-testid="tweetText"]')?.textContent,
}));
});
});
console.log(`Scraped ${result.items.length} items in ${result.stats.duration}ms`);
await browser.close();
Proxy Manager
Creating
import { ProxyManager } from './src/scraping/proxyManager.js';
// From array
const pm = new ProxyManager([
'http://user:[email protected]:8080',
'socks5://proxy2.example.com:1080',
'192.168.1.100:3128',
]);
// From file (one proxy per line)
const pm2 = new ProxyManager();
await pm2.loadFromFile('/path/to/proxies.txt');
// From environment variables
const pm3 = new ProxyManager();
pm3.loadFromEnv();
// Reads: XACTIONS_PROXIES (comma-separated) and XACTIONS_PROXY_FILE
Rotation Strategies
// Round-robin (deterministic)
const proxy = pm.getNext();
// Random selection
const proxy = pm.getRandom();
Health Tracking
The proxy manager automatically tracks success/failure rates:
// Mark results after each request
pm.markSuccess(proxy.url, responseTimeMs);
pm.markFailed(proxy.url);
// 3 consecutive failures → 10 minute blacklist (automatic)
// Blacklisted proxies are excluded from getNext()/getRandom()
// Get health stats
const stats = pm.getStats();
// [{ url, successes, failures, avgResponseTime, blacklisted }]
// Get only healthy proxies
const healthy = pm.getHealthy();
Testing
const results = await pm.testAll();
// Returns: [{ proxy, status: 'ok'|'failed', time }]
Read this before you trust the result. testAll() requests
https://httpbin.org/ip with the global fetch, which has no proxy support, so
the request does not go through the proxy under test. What it measures is
whether this machine can reach the internet, and it marks every proxy healthy or
failed on that one answer. Treat it as a connectivity smoke test, not as proxy
validation. To actually validate a proxy, drive a request through an agent that
honours it, for example undici's ProxyAgent, the same mechanism the account
pool uses for per-account proxies.
Supported Formats
| Format | Example |
|---|---|
| HTTP | http://proxy:8080 |
| HTTP with auth | http://user:pass@proxy:8080 |
| SOCKS5 | socks5://proxy:1080 |
| SOCKS4 | socks4://proxy:1080 |
| host:port | 192.168.1.1:3128 |
| user:pass@host:port | admin:secret@proxy:8080 |
Stealth Browser
Anti-detection Puppeteer wrapper that evades bot detection.
Features
- puppeteer-extra-plugin-stealth integration (with graceful fallback)
- 20 rotating user-agent strings (Chrome, Firefox, Safari, Windows, Mac, Linux)
- Random viewport dimensions (1280-1920 × 720-1080)
- WebDriver flag override (
navigator.webdriver = false) - Language, plugins, platform spoofing
- Proxy authentication support
- No-sandbox mode for Docker/CI
Launch Browser
import { launchStealthBrowser, createStealthPage } from './src/scraping/stealthBrowser.js';
const browser = await launchStealthBrowser({
proxy: 'http://proxy:8080', // Optional
headless: true, // Default: true
userDataDir: '/tmp/chrome1', // Optional: persistent profile
viewport: { width: 1920, height: 1080 }, // Optional: custom size
userAgent: 'Mozilla/5.0...', // Optional: override UA
});
const page = await createStealthPage(browser, {
proxy: { url: 'http://proxy:8080', username: 'user', password: 'pass' },
userAgent: 'Custom UA', // Optional override
});
Anti-Detection Patches
Applied automatically on every page:
| Patch | What it does |
|---|---|
navigator.webdriver |
Returns false instead of true |
navigator.languages |
Returns ['en-US', 'en'] |
navigator.plugins |
Returns fake plugin list |
navigator.platform |
Matches user-agent OS |
| User-Agent | Random selection from 20 real browser UAs |
| Viewport | Random dimensions within realistic range |
Pagination Engine
Handles infinite-scroll pages with deduplication, error recovery, and checkpointing.
Basic Usage
import { PaginationEngine } from './src/scraping/paginationEngine.js';
const engine = new PaginationEngine({
maxPages: 100, // Max scroll iterations
maxItems: 5000, // Stop after this many items
pageTimeout: 30000, // 30s timeout per scroll
scrollDelay: 1500, // Delay between scrolls (ms)
retries: 3, // Retries on error
deduplicateBy: 'id', // Deduplicate items by this field
onProgress: (stats) => console.log(stats),
});
const { items, stats } = await engine.scrapeWithPagination(page, extractFn);
Deduplication
// By field name
new PaginationEngine({ deduplicateBy: 'url' });
// By custom function
new PaginationEngine({
deduplicateBy: (item) => `${item.username}:${item.tweetId}`,
});
Checkpointing
Save progress and resume later:
// Save checkpoint mid-scrape
const checkpointPath = await engine.saveCheckpoint('my-scrape-001');
// Saved to ~/.xactions/scrape-checkpoints/my-scrape-001.json
// Resume from checkpoint
const engine2 = new PaginationEngine({ maxItems: 5000 });
await engine2.resume(checkpointPath);
const { items } = await engine2.scrapeWithPagination(page, extractFn);
Error Recovery
The engine automatically handles:
- Stuck detection. Stops after 3 consecutive empty scrolls.
- Error page detection. Pauses 60s on "Rate limit" or "Something went wrong".
- Scroll errors. Retries with a 5s delay.
Stats
const { items, stats } = await engine.scrapeWithPagination(page, extractFn);
console.log(stats);
// {
// total: 2500,
// duplicatesRemoved: 340,
// pagesScrolled: 85,
// errorsRecovered: 2,
// duration: 128000
// }
Retry Policy
Standalone retry utility with exponential backoff.
import { RetryPolicy } from './src/scraping/paginationEngine.js';
const retry = new RetryPolicy({
maxRetries: 3,
baseDelay: 2000, // Initial delay: 2s
maxDelay: 60000, // Cap at 60s
backoffMultiplier: 2, // Double each time: 2s → 4s → 8s
retryOn: ['timeout', 'network-error', 'rate-limit', 'empty-result'],
});
const result = await retry.execute(async () => {
const response = await fetch('https://api.example.com/data');
if (!response.ok) throw new Error('Request failed');
return response.json();
});
Error Classification
| Error Type | Triggered By | Retried? |
|---|---|---|
timeout |
AbortError, TimeoutError | ✅ |
network-error |
ECONNREFUSED, ENOTFOUND, fetch failures | ✅ |
rate-limit |
429 status, "Rate limit" in message | ✅ |
empty-result |
Custom "empty" errors | ✅ |
| Other | Unrecognized errors | ❌ (thrown immediately) |
Dataset Storage
The PaginationEngine stores results in ~/.xactions/datasets/ as JSON files, compatible with the Apify dataset format.
CLI
# List datasets
xactions dataset list
# Export a dataset
xactions dataset export my-scrape --format json --output data.json
xactions dataset export my-scrape --format csv --output data.csv
# Delete
xactions dataset delete my-scrape
API
GET /api/datasets/ # List
GET /api/datasets/:name # Get items
GET /api/datasets/:name/export?format=json
DELETE /api/datasets/:name
Scraper Adapters
XActions includes adapter wrappers for multiple scraping backends (src/scrapers/adapters/):
| Adapter | Uses | Best For |
|---|---|---|
puppeteer |
Puppeteer + stealth | Default. Full JavaScript rendering |
playwright |
Playwright | Alternative to Puppeteer |
cheerio |
cheerio | Static HTML (fastest) |
got-jsdom |
got + jsdom | Lightweight JS rendering |
crawlee |
Crawlee framework | Large-scale crawling |
selenium |
selenium-webdriver | Legacy compatibility |
All adapters implement the same BaseScraper interface:
import { getAdapter, getAvailableAdapter, listAdapters } from 'xactions/scrapers';
const adapter = await getAdapter('puppeteer'); // or 'playwright', 'cheerio', ...
const browser = await adapter.launch({ headless: true });
const page = await adapter.newPage(browser);
await adapter.goto(page, 'https://x.com/nasa');
const html = await adapter.getContent(page);
await adapter.closePage(page);
await adapter.closeBrowser(browser);
getAdapter is async because it lazy-imports the backend, so an uninstalled
optional dependency costs nothing until you ask for it. getAvailableAdapter()
walks a fallback chain and returns the first backend whose package is actually
present. XACTIONS_SCRAPER_ADAPTER sets the process default.
Cross-Platform Scrapers
XActions scrapers support multiple social platforms:
| Platform | Module | Features |
|---|---|---|
| Twitter/X | src/scrapers/twitter/ |
Profiles, followers, tweets, search, media |
| Bluesky | src/scrapers/bluesky/ |
Profiles, followers, posts |
| Mastodon | src/scrapers/mastodon/ |
Profiles, followers, toots (any instance) |
| Threads | src/scrapers/threads/ |
Profiles, followers, posts |
import scrapers from 'xactions/scrapers';
import bluesky from 'xactions/scrapers/bluesky';
import mastodon from 'xactions/scrapers/mastodon';
// Twitter: Puppeteer page first, and the page must be logged in.
const profile = await scrapers.scrapeProfile(page, 'nichxbt');
// Bluesky: an AT Protocol agent first. Public reads need no credentials.
const agent = await bluesky.createAgent();
const bskyProfile = await bluesky.scrapeProfile(agent, 'bsky.app');
// Mastodon: a client bound to an instance.
const client = mastodon.createClient({ instance: 'https://mastodon.social' });
const mastoProfile = await mastodon.scrapeProfile(client, 'Gargron');
Every platform module names its read functions scrapeProfile,
scrapeFollowers, scrapeFollowing, scrapeTweets and searchTweets, so one
scrape(platform, action, options) call dispatches across all of them. See
scrapers.md.
GraphQL Query ID Discovery
Every GraphQL call to x.com is addressed by a persisted query ID
(/i/api/graphql/<queryId>/<operationName>). X rotates those IDs whenever it
ships a new web bundle, and a stale ID answers 404 Query not found. That is
the single most common way a no-API client breaks, and the reason a scraper
that worked last month stops working with no code change.
The HTTP scraper refreshes its IDs the way the web client does, from x.com's
own JavaScript bundles (src/scrapers/twitter/http/queryIds.js):
- Load an x.com page that still ships the classic client (
/home, then/i/flow/login).https://x.com/itself is a server-rendered shell with no bundles, so it is only a last resort. - Read the inline webpack runtime's chunk manifest (chunk name to hash) and the
main.*.jsURL. - Download
mainplus the feature chunks that carry the operations XActions uses (bundle.LoggedInMain,ondemand.HoverCard, the Bookmarks, HomeTimeline and TweetActivityshared~chunks, ...). About 2 MB in total. A full sweep of every chunk (scope: 'full') is ~130 MB and only worth it when you need an operation outside the table. - Extract every
{queryId, operationName, operationType}descriptor and write them to~/.xactions/query-ids.json(or$XACTIONS_HOME/query-ids.json) with afetchedAttimestamp.
Resolution order
For any operation: the cached/discovered ID, then the hardcoded table in
src/scrapers/twitter/http/endpoints.js. Offline behaviour is unchanged: with
no cache and no network the hardcoded IDs are used exactly as before. The cache
wins over the table because it was read from x.com's live bundle, and the
table value may have rotated since it was pinned.
When it refreshes
TwitterHttpClient handles this on its own:
- A GraphQL call that fails with
404, or400with an error naming the persisted query, triggers one refresh and one retry with the new ID. If the refreshed ID is unchanged, or discovery fails (offline), the original error is rethrown. Mutations are only retried when the first attempt was rejected for a stale ID, so nothing is ever sent twice. - When the cache is older than 24 hours, a refresh starts in the background (at most once per hour per process) without blocking the call.
Pass autoRefreshQueryIds: false to the client to turn both off. Under vitest
the default is off, so unit tests never reach the network unless they opt in.
API
import {
discoverQueryIds,
refreshQueryIds,
getQueryId,
resolveOperation,
queryIdStatus,
} from './src/scrapers/twitter/http/queryIds.js';
// Probe x.com now and persist the result
const { count, source, cachePath } = await discoverQueryIds();
console.log(`${count} operations from ${source.mainBundle} -> ${cachePath}`);
// Cache > discovered > hardcoded, never touches the network
getQueryId('TweetDetail'); // 'XMOz5h24KAZ86qKffKTLdQ'
resolveOperation('Likes'); // { queryId, operationName: 'Favoriters', source: 'cache' }
// Force a refresh (concurrent callers share one in-flight discovery)
await refreshQueryIds({ fetch: myProxiedFetch, scope: 'full' });
// For diagnostics (`xactions doctor` reads this)
queryIdStatus(); // { cached, fetchedAt, count, cachePath, stale }
resolveOperation accepts either an operationName (Favoriters) or a key of
the hardcoded table (Likes). src/client/api/graphqlQueries.js reads the same
cache on every access, so the high-level Scraper client benefits without
changes.
Verifying against live x.com
node --input-type=module -e '
import { discoverQueryIds } from "./src/scrapers/twitter/http/queryIds.js";
import { GRAPHQL } from "./src/scrapers/twitter/http/endpoints.js";
const r = await discoverQueryIds({ persist: false });
for (const e of Object.values(GRAPHQL)) {
const live = r.operations[e.operationName]?.queryId;
console.log(e.operationName.padEnd(26), live === e.queryId ? "match" : `differs (${live})`);
}'
On 2026-08-27 this found 148 operations and 22 of the 26 hardcoded IDs had
rotated, including UserByScreenName, TweetDetail and SearchTimeline.
Keeping the Endpoint Table Fresh
Runtime discovery (above) is what saves a running scrape. It does not help a
fresh install with no cache and no network, which falls back to the table in
src/scrapers/twitter/http/endpoints.js. That table used to be typed in by
hand, so it was only ever as current as the last person who remembered to
refresh it.
It is generated now. fa0311/TwitterInternalAPIDocument
(MIT) runs a bot that statically analyses x.com's JavaScript bundles once a day
and commits the result as JSON. npm run sync:endpoints reads it and rewrites
src/scrapers/twitter/http/x-endpoints.generated.js:
npm run sync:endpoints # fetch and rewrite
npm run sync:endpoints:check # exit 1 if the committed table has fallen behind
node scripts/sync-x-endpoints.mjs --json
The report names exactly what moved: query IDs that rotated, operations x.com
added or retired, and feature switches whose value flipped. It also cross-checks
the curated table and exits non-zero if an operation XActions tracks has
disappeared from x.com's bundles, which is the failure that otherwise shows up
weeks later as a 404 Query not found in someone's issue.
Why the generated data lives in its own module
endpoints.js is mostly hand-written behaviour: the request-variable shapes per
operation, the rate-limit budgets, validateEndpoints. A generator that rewrote
a marked region inside it would be one bad parse away from eating that code, and
every regeneration would produce a diff mixing data with logic. So the generator
only ever writes x-endpoints.generated.js, which contains nothing but data,
and endpoints.js imports it and keeps the parts a human decides:
In endpoints.js (hand-written) |
In x-endpoints.generated.js (overwritten every sync) |
|---|---|
TRACKED_OPERATIONS: which operations XActions uses, under which key |
OPERATIONS: every operation x.com ships, with its query ID and type |
QUERY_ID_PINS: hold an operation at a known-good ID |
FEATURE_VALUES: every feature switch, with the value x.com's client sends |
FEATURE_PINS, FIELD_TOGGLE_VALUES |
FEATURE_NAMES, FIELD_TOGGLE_NAMES, and per-operation index lists |
buildGraphQLVariables, RATE_LIMITS, REST |
REST_V11: x.com's v1.1 dispatch table, path plus method |
Hand-pinning an operation is therefore still one edit, and it survives the next sync:
// src/scrapers/twitter/http/endpoints.js
export const QUERY_ID_PINS = Object.freeze({
SearchTimeline: 'hyPfJYJ_XAtDYoslQc-Rgg', // held while #123 is investigated
});
Resolution order, unchanged
Paths below are relative to the repository root. The package's exports map
publishes xactions/scrapers/twitter/http (the index) but not the individual
modules inside it, so a consumer reaches resolveGraphQL and the rest through
that index or through a direct path.
The discovery cache still wins over the table, and the table is still the offline fallback:
import { resolveGraphQL } from './src/scrapers/twitter/http/endpoints.js';
resolveGraphQL('SearchTimeline');
// { queryId, operationName: 'SearchTimeline', source: 'cache' | 'hardcoded' }
What is new is that a key the curated table does not name still resolves, from the generated table, so reaching one of the operations XActions has no wrapper for does not mean editing a file first:
resolveGraphQL('BirdwatchFetchNotes'); // Community Notes on a post
resolveGraphQL('AudioSpaceSearch');
Per-operation feature switches
X rejects a request that omits a switch the operation requires
(The following features cannot be null: ...). DEFAULT_FEATURES is the union
across every tracked operation, which is the safe default and what the HTTP
client sends. Upstream also records which switches each operation declares
individually, which is narrower and closer to what the web client actually
sends:
import { operationFeatures, operationFieldToggles, buildGraphQLUrl, resolveGraphQL } from './src/scrapers/twitter/http/endpoints.js';
const { queryId, operationName } = resolveGraphQL('BirdwatchFetchNotes');
const url = buildGraphQLUrl(
queryId,
operationName,
{ tweet_id: '1234567890' },
operationFeatures(operationName),
operationFieldToggles(operationName),
);
A few operations carry a feature family nobody else sends (the six
responsive_web_birdwatch_* switches, for instance). Those are listed in
SPECIALISED_OPERATIONS and deliberately kept out of DEFAULT_FEATURES, so a
plain UserTweets call does not advertise flags the web client would never send
with it. Call them through operationFeatures() instead.
Where "last verified" comes from
Both generated modules record the upstream commit they were built from, when that commit was published, and when we fetched it, so a freshness claim has a source rather than a date somebody typed into a comment:
import { ENDPOINT_TABLE_SOURCE } from './src/scrapers/twitter/http/endpoints.js';
ENDPOINT_TABLE_SOURCE;
// { repo: 'fa0311/TwitterInternalAPIDocument', ref: 'develop', commit, committedAt,
// fetchedAt, files: ['docs/json/GraphQL.json', 'docs/json/v1.1.json'],
// operations, queries, mutations, featureSwitches, fieldToggles, restPaths }
What upstream does not cover
docs/json/FreezeObject.json. Described upstream as frozen constants, and it is: Redux action-type triples, keyboard-shortcut maps, entity-name maps. A sweep of all 1921 objects in it found no feature-flag defaults, so the sync does not read it. The flag values come from the per-operationfeatureSwitchmetadata insideGraphQL.json.- Field-toggle values. Upstream records which toggles an operation accepts,
but not what value the client passes, because a toggle is a request parameter
rather than something baked into the bundle.
FIELD_TOGGLE_VALUESinendpoints.jsholds the values; a toggle named by an operation and absent there is sent asfalse. - X Jobs. No operation in x.com's bundles carries
Jobin its name, so there is nothing to pin. If the hiring surface is served by GraphQL at all, it is under a name that does not say so. - Two thirds of our v1.1 REST paths. Upstream's
v1.1.jsoncovers the dispatch table the web client builds at boot, which confirmsfriendships/create,friendships/destroy,blocks/*,mutes/users/*,dm/inbox_initial_state,dm/conversationandtrends/available. The rest ofREST(guest/activate,account/pin_tweet,dm/new2,/2/notifications/*,/2/guide.json,trends/place) is dispatched elsewhere and stays hand-maintained.
Browser Identity
Every request the HTTP-only client makes carries a browser User-Agent, because a
bare fetch() from Node gets POST /1.1/guest/activate.json answered with a
misleading 404 Sorry, that page does not exist. src/client/auth/userAgent.js
provides it, and two things about how it does that are deliberate.
The strings are generated. They come from
fa0311/latest-user-agent (MIT),
which runs the real browsers in CI and commits both the User-Agent and the full
header set each one sends. npm run sync:user-agents rewrites
src/client/auth/userAgents.generated.js from it. A hand-maintained pool goes
stale the week it is written, and a User-Agent two major versions behind is
itself a signal: no real Chrome install stays that far back.
npm run sync:user-agents # fetch and rewrite
npm run sync:user-agents:check # exit 1 if a browser has shipped a new version since
Upstream's CI runs on Linux, so every string it publishes carries the
X11; Linux x86_64 platform token. The generator keeps upstream's version and
browser identity exactly as published and substitutes the platform token to
cover Windows and macOS. That token carries no version and has been frozen for
years in both engines, which is why substituting it is safe and inventing a
version number would not be.
One profile per session, not one per request. The pool used to pick a random string on every call, so a single session would claim to be Chrome on Windows, then Firefox on macOS, then Chrome on Linux, from one IP address, inside one cookie jar. That is a stronger tell than any single stale string: real browsers do not change identity mid-session. The default is now one profile, chosen once and held for the life of the process, carrying its own matching client hints.
import {
sessionUserAgent,
sessionProfile,
profileHeaders,
clientHintHeaders,
} from './src/client/auth/userAgent.js';
sessionUserAgent(); // the same string every call, for this process
sessionProfile().id; // 'chrome-windows'
profileHeaders(); // user-agent + accept-language + Sec-CH-UA, all agreeing
clientHintHeaders(); // just the Sec-CH-UA trio; empty for a Firefox profile
Rotation is still there, because it is the right answer when each request really is a different identity: a pool of accounts, each behind its own proxy, each with its own cookie jar. It is an explicit choice rather than the default.
import { configureUserAgent, rotateUserAgent, randomUserAgent, resetUserAgentSession } from './src/client/auth/userAgent.js';
randomUserAgent({ rotate: true }); // rotate this one call
configureUserAgent({ rotate: true }); // rotate for the whole process
configureUserAgent({ profileId: 'firefox-macos' }); // or pin one deliberately
resetUserAgentSession(); // new identity: new proxy, new account
XACTIONS_ROTATE_USER_AGENT=1 turns rotation on without a code change. Every
previous export still works: USER_AGENTS is still the list of strings,
DEFAULT_USER_AGENT is still a member of it, and randomUserAgent() still
returns something from the pool. It just no longer returns a different one every
time unless you ask it to.
The profiles cover Chrome and Firefox on Windows, macOS and Linux, plus Edge on
Windows. A Firefox profile carries no Sec-CH-UA headers at all, because Gecko
sends none; a User-Agent from one row with client hints from another is exactly
the inconsistency a fingerprinter looks for, which is why they travel together
in one profile rather than being picked separately.
Account Pool and Resumable Scrapes (HTTP scraper)
One X session gets roughly 50 GraphQL calls per operation per 15-minute window, which caps a single-account follower scrape at about 1,000 users before it stalls. The HTTP scraper solves this the way twscrape does: a pool of accounts in SQLite, per-account per-operation rate-limit tracking from the x-rate-limit-* headers, and automatic rotation. Interrupted scrapes resume from a saved cursor, the idea behind Scweet's resume mode.
Both live in src/scrapers/twitter/http/ and are published on the package subpath xactions/scrapers/twitter/http.
Account pool
import { createAccountPool, createPooledClient, createCheckpoint, scrapeFollowers } from 'xactions/scrapers/twitter/http';
import fs from 'node:fs';
// Accounts persist in $XACTIONS_HOME/accounts.db (default ~/.xactions/accounts.db).
// Cookies accept a header string, Netscape cookies.txt text, a Cookie-Editor JSON
// export, Playwright storageState, an array of { name, value }, or authToken + ct0.
const pool = createAccountPool({
accounts: [
{ name: 'main', authToken: process.env.X_AUTH_TOKEN_1, ct0: process.env.X_CT0_1 },
{ name: 'alt', cookies: fs.readFileSync('./alt-cookies.txt', 'utf8'), proxy: 'socks5://127.0.0.1:1080' },
],
});
// Manage it like a CLI would
pool.add({ name: 'third', cookies: 'auth_token=...; ct0=...' });
pool.importCookies('fourth', fs.readFileSync('./cookies.json', 'utf8'));
pool.remove('third');
console.log(pool.list()); // [{ name, proxy, locked, lockReason, lastUsed, limits: { Followers: { remaining, resetAt, coolingDown } } }]
console.log(pool.stats()); // { total, locked, leased, available, coolingDown, nextResetAt, accounts }
Each account gets its own TwitterHttpClient with its cookies and proxy (proxies route through undici's ProxyAgent). acquire(operation) leases the least-recently-used account that is unlocked and outside its rate-limit window for that operation; when every account is cooling down it waits for the earliest reset (bounded by maxWaitMs, default 15 minutes) and throws an AccountPoolError naming the next reset when the wait would exceed the budget.
const lease = await pool.acquire('Followers', { maxWaitMs: 60_000 });
try {
const page = await lease.client.graphql(queryId, 'Followers', variables);
} finally {
lease.release();
}
pool.markLocked('alt', 'challenge required'); // skipped by acquire until pool.unlock('alt')
Pooled client
createPooledClient(pool) exposes the same graphql(), request(), rest(), and graphqlPaginate() as a single client and passes as the client argument of every scraper. A 429 (or a response whose x-rate-limit-remaining reaches 0) records the window and retries the same call on the next account; a 401/403 locks the account and moves on. After every account has been tried the last error is rethrown.
const client = createPooledClient(pool, {
maxAccounts: 3, // most accounts one call may try (default: pool size)
onRotate: ({ from, operation, reason }) => console.log(`${operation}: ${from} ${reason}`),
});
const followers = await scrapeFollowers(client, 'elonmusk', { limit: 5000 });
Resumable scrapes
A checkpoint is a small JSON file ({ cursor, count, updatedAt, meta }) written atomically after every page under $XACTIONS_HOME/checkpoints/. Pass it as the checkpoint option of scrapeFollowers, scrapeFollowing, scrapeLikers, scrapeRetweeters, scrapeListMembers, scrapeTweets, scrapeTweetsAndReplies, or searchTweets. A run that starts with a saved checkpoint continues from its cursor, and the count already collected is subtracted from limit, so re-running the same command finishes the job. The file is deleted when the scrape completes.
const checkpoint = createCheckpoint({ key: 'followers:elonmusk' });
// Run 1 dies at page 400 (rate-limit wall, network drop, Ctrl-C).
// Run 2, same call, starts at the page-400 cursor with the remaining budget.
const rest = await scrapeFollowers(client, 'elonmusk', { limit: 50000, checkpoint });
console.log(checkpoint.resume()); // null once finished; otherwise { cursor, count, updatedAt, meta }
checkpoint.clear(); // start over
Because a resumed run returns only the users fetched after the cursor, write results out as they arrive (for example from onProgress, or by appending each run's output) rather than relying on a single final array.
Environment Variables
| Variable | Description |
|---|---|
XACTIONS_PROXIES |
Comma-separated proxy list |
XACTIONS_PROXY_FILE |
Path to proxy list file |
XACTIONS_SESSION_COOKIE |
Default X/Twitter auth token |
XACTIONS_ROTATE_USER_AGENT |
1 to pick a fresh browser profile per request instead of holding one per session |
PUPPETEER_EXECUTABLE_PATH |
Custom Chrome path |
Tips
- Start without proxies. Most use cases don't need them for moderate volumes.
- Use checkpointing for large scrapes (>1000 items), so an interruption resumes.
- Set
deduplicateByto avoid counting the same item twice during scrolling - Monitor
stats.errorsRecovered. High values indicate rate limiting. - Rotate user data dirs for multi-account scraping to maintain separate cookies
- Use the stealth browser even without proxies. The anti-detection patches alone reduce block rates.
Request Signing (x-client-transaction-id)
x.com's web client attaches an x-client-transaction-id header to every GraphQL
and internal REST call it makes. A client that omits it is trivially separable
from a browser, and projects that added it report that sessions carrying a valid
one survive longer before being soft-blocked. XActions now generates it
(src/scrapers/twitter/http/transactionId.js) and the HTTP scraper signs every
request with it.
What the value is
The header is derived per request from four things: the HTTP method, the request
pathname, the current second, and a {verification key, animation key} pair the
page itself carries.
- The verification key is the base64 blob in the
<meta name="twitter-site-verification">tag onx.com/home. - The animation key is computed by walking one of the four
loading-x-anim-*SVG paths on that same page along a cubic-bezier curve. Which of the four paths, which row of it, and where along the curve are all decided by byte indices that live inside theondemand.swebpack chunk.
Those two are hashed with the method, path and timestamp (SHA-256), packed with the key bytes behind a random XOR mask, and base64-encoded. The value is different on every request, which is why signing happens per request rather than per session.
Where the keys come from
Extraction runs once and the result is cached under
$XACTIONS_HOME/transaction-keys.json (default ~/.xactions/) with a
fetchedAt timestamp. Cached keys are reused for 24 hours; signing after that
first call is one SHA-256 over a short string.
With a cold cache, two lanes are tried in order:
- Pair dictionary (fast path). A published list of known-good
{animationKey, verification}pairs (fa0311/x-client-transaction-id-pair-dict, MIT). One 5 KB fetch, no bundle parsing. The header only has to be internally consistent, so a harvested pair signs exactly as well as the page's own: on a live check, x.com served two different verification keys on two page loads half an hour apart, so the page's key is not a fixed per-deploy value that anything could be matched against. - Live parse (fallback). Load
x.com/home, read the verification key and the animation paths out of the HTML, resolve theondemand.schunk through the same webpack manifest parser that GraphQL query-ID discovery uses, and extract the key-byte indices from it. About 300 KB of traffic.
Measured on a cold cache: the dictionary lane takes ~200 ms, the live lane
~350 ms. Set XACTIONS_TXID_SOURCE=live to try the bundles first.
Failure is never fatal
Every failure path returns null and the request goes out unsigned, exactly as
it did before this module existed. A discovery failure is remembered for ten
minutes so a machine with no route to x.com does not pay for a failed fetch on
every call, and keys that are past their 24-hour freshness window keep signing
while a refresh is failing. Nothing in this module throws into a request.
Turning it off
Signing is on by default outside vitest.
XACTIONS_TRANSACTION_ID=0 node src/cli/index.js profile nasa # off (also: off, false, no)
XACTIONS_TXID_SOURCE=live node your-script.js # prefer the live parse
import { TwitterHttpClient } from './src/scrapers/twitter/http/client.js';
const client = new TwitterHttpClient({ transactionId: false }); // off for this client
A caller that sets its own x-client-transaction-id header keeps it.
API
import {
getTransactionId,
initializeTransactionId,
transactionIdStatus,
configureTransactionId,
} from './src/scrapers/twitter/http/transactionId.js';
// Sign one request. Returns null when signing is off or unavailable.
const id = await getTransactionId('GET', 'https://x.com/i/api/graphql/abc/UserByScreenName?variables=%7B%7D');
if (id) headers['x-client-transaction-id'] = id;
// Warm the cache up front (concurrent callers share one initialisation)
await initializeTransactionId();
// For diagnostics
transactionIdStatus();
// { enabled, cached, source: 'pairs'|'live', fetchedAt, stale, cachePath, preferredSource }
// Process-wide defaults: proxied fetch, custom cache directory, forced lane
configureTransactionId({ fetch: myProxiedFetch, source: 'live' });
Only the pathname is signed, so a full URL and a bare path produce the same value; the query string is ignored.
Both request lanes carry it
TwitterHttpClient signs inside its retry loop, so every attempt gets a fresh
value rather than replaying one. GuestTokenManager.getHeaders() signs when it
is told which request the headers are for:
import { GuestTokenManager } from './src/scrapers/twitter/http/guest.js';
const guest = new GuestTokenManager();
const url = 'https://x.com/i/api/graphql/abc/UserByScreenName?variables=%7B%7D';
const headers = await guest.getHeaders({ method: 'GET', path: url });
const res = await fetch(url, { headers });
Calling getHeaders() with no argument returns the same headers it always did.
What the header does, measured
On the guest tier the effect is not observable. Against
UserByScreenName on one guest token, alternating signed and unsigned requests:
every call answered 200, the rate-limit budget was the same 150 per window
and decremented by one either way, and a deliberately bogus header value was
accepted too. X does not appear to validate the header for guest-token reads.
The reported benefit is a session-lifetime effect, so confirming it needs an
authenticated run: sign in with a real auth_token, drive a normal workload for
days with XACTIONS_TRANSACTION_ID=1 and again with =0, and compare how long
the cookie survives before it is invalidated or starts returning empty
timelines. Nothing shorter than that will show a difference.
Verifying the generator against live x.com
node --input-type=module -e '
import { discoverFromLiveBundles, generateTransactionId } from "./src/scrapers/twitter/http/transactionId.js";
const keys = await discoverFromLiveBundles({});
console.log("key ", keys.key);
console.log("animation ", keys.animationKey);
console.log("chunk ", keys.chunkUrl);
console.log("signed GET ", await generateTransactionId({ ...keys, method: "GET", path: "/i/api/graphql/abc/UserByScreenName" }));'