Passively capture tweets as you browse X/Twitter
Installation · How It Works · Stealth · Output Format · Configuration · License
xTap is a Chrome extension that silently intercepts the GraphQL API responses X/Twitter already sends to your browser and saves every tweet you encounter as structured JSONL. No scraping, no extra requests — just a tap on the data already flowing through.
- Zero footprint — no additional network requests; captures what Chrome already receives
- Structured output — each tweet saved as a clean JSON object with author, metrics, media, and more
- Article support — long-form X articles are captured with full text, inline image references, and Draft.js block structure
- Video download — download videos from tweets using yt-dlp (or direct MP4 fallback) via the extension popup. Requires the HTTP daemon. Note: unlike passive capture, video downloads make additional network requests to X and are not stealth.
- Pause / resume — click the extension icon to toggle capture on the fly
- Live counter — badge on the extension icon shows tweets captured this session
- Multi-tab aware — multiple X tabs feed into the same service worker with shared deduplication
- Debug logging — optional toggle to write timestamped service worker logs to a date-rotated file
- Cross-platform — works on macOS, Linux, and Windows
X/Twitter GraphQL responses
│
▼
┌────────────────────────────┐
│ content-main.js │ MAIN world
│ patches fetch & XHR │
└──────────────┬─────────────┘
│ CustomEvent (random name)
▼
┌────────────────────────────┐
│ content-bridge.js │ ISOLATED world
│ relays to service worker │
└──────────────┬─────────────┘
│ chrome.runtime.sendMessage
▼
┌────────────────────────────┐
│ background.js │ Service worker
│ parse, dedup, batch │
└──────────┬─────────┬───────┘
│ │
HTTP │ │ native messaging
(primary) │ │ (token bootstrap
│ │ + data fallback)
▼ ▼
┌──────────────┐ ┌──────────────┐
│ xtap_daemon │ │ xtap_host.py │
│ (HTTP) │ │ (stdio) │
└──────┬───────┘ └──────┬───────┘
│ │
▼ ▼
tweets-YYYY-MM-DD.jsonl
- A MAIN world content script patches
fetchandXMLHttpRequest.open()to observe GraphQL responses as they arrive - Payloads are relayed via a random-named
CustomEventto an ISOLATED world bridge, which forwards them to the service worker - The service worker parses, normalizes, deduplicates, and batches tweets
- Batches are sent to disk via one of two transports:
- HTTP daemon: a standalone
xtap_daemon.pyprocess on127.0.0.1:17381, managed by launchd (macOS), systemd (Linux), or Scheduled Task (Windows). On macOS, it runs outside Chrome's TCC sandbox and can write to protected paths like~/Documentsand iCloud Drive - Native messaging:
xtap_host.pyover Chrome's stdio protocol — used at startup to retrieve the daemon's auth token (GET_TOKEN), and as a data transport fallback if HTTP is unavailable
- HTTP daemon: a standalone
X is rolling out stricter detection for automation and bots. The key line: "If a human is not tapping on the screen, the account and all associated accounts will likely be suspended."
xTap is not a bot. It doesn't post, like, follow, scroll, or make any API calls on your behalf. It sits in the background and reads the responses X already sent to your browser while you browse normally. From X's server-side perspective, your account looks identical to any other user — because you are a normal user. There is no extra traffic to detect.
The risk of automation enforcement applies to tools that act as you (auto-liking, auto-following, automated scrolling, headless browsers). xTap does none of that. It's the equivalent of keeping DevTools open and saving the Network tab — just automated into structured JSONL.
Even though passive interception is inherently low-risk, xTap avoids leaving unnecessary traces:
- No extra network requests — only reads responses the browser already received; nothing to spot in a network log
- Native-looking API patches —
fetchandXMLHttpRequest.prototype.openare patched withtoString()overrides that return[native code], passing the most common runtime integrity checks - No expando properties — XHR URL tracking uses a
WeakMapinstead of attaching properties to the XHR instance, which would be trivially detectable - Random event channel — the MAIN↔ISOLATED world bridge uses a
CustomEventwith a per-page-load random name; the<meta>beacon that communicates the name is removed immediately after the bridge reads it - Zero DOM footprint — no injected UI, no page modifications; everything lives in the popup and service worker
- Zero console output in page context — all logging happens in the service worker and parser, which run outside the page's JavaScript environment
- Minimal permissions — only
storageandnativeMessaging; nowebRequest, no host permissions beyondx.com/twitter.com/127.0.0.1 - Jittered flush timing — batches are flushed on a randomized interval to avoid a clockwork-regular pattern
These measures don't make detection impossible — a determined page script could still compare prototype references or probe for patched behavior — but they avoid the low-hanging signals that fingerprinting scripts typically check. More importantly, there's nothing to detect server-side because xTap generates zero network activity of its own.
| Requirement | |
|---|---|
| Browser | Google Chrome |
| Runtime | Python 3 |
| OS | macOS, Linux, or Windows |
yt-dlp (optional) |
For best-quality video downloads |
- Open
chrome://extensions - Enable Developer mode (top right)
- Click Load unpacked and select the
xtap/directory - Copy the extension ID shown on the card
macOS
cd native-host
./install.sh <your-extension-id>This installs the native messaging host and an HTTP daemon (xtap_daemon.py) that runs via launchd. The daemon runs independently of Chrome's process tree and has its own TCC permissions, so it can write to protected paths like ~/Documents and iCloud Drive. The installer captures your current PATH so the daemon can find tools like yt-dlp.
The extension automatically detects the daemon and uses it as the primary transport, falling back to native messaging if unavailable.
Linux
cd native-host
./install.sh <your-extension-id>This installs the native messaging host and an HTTP daemon (xtap_daemon.py) that runs as a systemd user service. The daemon enables video downloads and provides the same HTTP transport as macOS.
Windows (PowerShell)
cd native-host
.\install.ps1 <your-extension-id>This installs the native messaging host and an HTTP daemon (xtap_daemon.py) as a Windows Scheduled Task that starts at logon. The daemon enables video downloads and provides the same HTTP transport as macOS/Linux.
Open x.com and browse normally. The badge counter on the extension icon shows how many tweets have been captured this session. Click the icon to see stats and pause/resume capture.
After updating the extension: If you reload xTap at
chrome://extensions, you must also hard-reload any open X tabs (Cmd+Shift+R/Ctrl+Shift+R). The content scripts that intercept API responses are injected at page load — stale scripts from before the update won't connect to the new service worker.
After updating the extension files:
- Re-run the installer (
install.shon macOS/Linux,install.ps1on Windows) — this updates the daemon's PATH (required for yt-dlp support) and picks up new Python code - Reload the extension at
chrome://extensions - Hard-reload any open X tabs (
Cmd+Shift+R/Ctrl+Shift+R)
If you previously installed xTap before v0.13.0 on macOS, re-running install.sh is required for video download support — the daemon needs an updated launchd configuration to find yt-dlp on your PATH. On Linux and Windows, the daemon is new in this version — running the installer will set it up automatically.
The easiest way to change where tweets are saved is through the extension popup — click the xTap icon and enter your preferred path in the Output directory field.
Alternatively, set the XTAP_OUTPUT_DIR environment variable before launching Chrome:
export XTAP_OUTPUT_DIR="$HOME/Documents/xtap-data"| Setting | Default | Description |
|---|---|---|
| Popup "Output directory" | (empty — uses default) | Overrides the output path per-session |
XTAP_OUTPUT_DIR env var |
~/Downloads/xtap |
Fallback when no popup setting is configured |
| Debug logging toggle | Off | Writes service worker logs to debug-YYYY-MM-DD.log in the output directory |
| Discovery mode toggle | Off | Logs endpoint response shapes and can dump full JSON responses to disk |
macOS note: On macOS, the HTTP daemon (installed via
install.sh) runs outside Chrome's TCC sandbox and can write to protected paths like~/Documentsand iCloud Drive after a one-time macOS permission prompt. If the daemon is unavailable and the extension falls back to native messaging, protected paths will fail with a permission error —~/Downloadsis the safe default in that case.
Output is written to daily files (tweets-YYYY-MM-DD.jsonl). Each line is a self-contained JSON object:
For regular tweets, is_article and article are absent. For articles, text contains a markdown-style rendering of the article with inline image references pointing to media/<tweet_id>/.
xTap/
├── manifest.json # Chrome MV3 extension manifest
├── background.js # Service worker — parsing, dedup, transport
├── content-main.js # MAIN world — patches fetch/XHR, emits events
├── content-bridge.js # ISOLATED world — relays events to service worker
├── popup.html/js/css # Extension popup UI
├── icons/ # Extension icons
├── lib/ # Shared utilities
└── native-host/
├── xtap_core.py # Shared file I/O logic
├── xtap_host.py # Native messaging host (Python, stdio)
├── xtap_daemon.py # HTTP daemon
├── com.xtap.daemon.plist # launchd plist template (macOS)
├── com.xtap.daemon.service # systemd unit template (Linux)
├── install.sh # Installer for macOS / Linux
├── install.ps1 # Installer for Windows
├── xtap_host.bat # Windows native host wrapper
└── xtap_daemon.bat # Windows daemon wrapper
After modifying extension files (background.js, lib/, content-*.js, popup.*), reload the extension at chrome://extensions and hard-reload any open X tabs.
After modifying Python host files (xtap_core.py, xtap_host.py, xtap_daemon.py), the native host picks up changes on next Chrome restart. To restart the HTTP daemon immediately:
macOS (launchd):
launchctl kickstart -k gui/$(id -u)/com.xtap.daemon # restart
launchctl bootout gui/$(id -u)/com.xtap.daemon # stop
launchctl print gui/$(id -u)/com.xtap.daemon # status
tail -f ~/.xtap/daemon-stderr.log # logsLinux (systemd):
systemctl --user restart com.xtap.daemon # restart
systemctl --user stop com.xtap.daemon # stop
systemctl --user status com.xtap.daemon # status
journalctl --user -u com.xtap.daemon -f # logsWindows (Scheduled Task, PowerShell):
Stop-ScheduledTask -TaskName xTapDaemon; Start-ScheduledTask -TaskName xTapDaemon # restart
Stop-ScheduledTask -TaskName xTapDaemon # stop
Get-ScheduledTask -TaskName xTapDaemon # status
Get-Content ~\.xtap\daemon-stderr.log -Tail 50 -Wait # logspython3 -m pytest tests/test_xtap_core.py -v
node --test tests/tweet-parser.test.mjsCI runs these on every push to main with coverage uploaded to Codecov.
MIT — use it however you like.
{ "id": "1234567890", "url": "https://x.com/handle/status/1234567890", "created_at": "2024-01-01T00:00:00.000Z", "author": { "id": "987654321", "username": "handle", "display_name": "Display Name", "verified": false, "is_blue_verified": true, "follower_count": 1234 }, "text": "Full tweet text...", "lang": "en", "metrics": { "likes": 10, "retweets": 5, "replies": 2, "views": 1000, "bookmarks": 1, "quotes": 0 }, "media": [], "urls": [], "hashtags": [], "mentions": [], "in_reply_to": null, "quoted_tweet_id": null, "conversation_id": "1234567890", "is_retweet": false, "retweeted_tweet_id": null, "is_subscriber_only": false, // true for subscriber-only tweets "is_article": true, // present only for long-form articles "article": { // present only for long-form articles "title": "Article Title", "text": "Rendered plain text with  refs", "blocks": [], // raw Draft.js content_state blocks "media": [{ // article image references "id": "...", "url": "https://pbs.twimg.com/...", // original CDN URL "filename": "image.png", "local_path": "media/<tweet_id>/image.png", "width": 1200, "height": 800 }] }, "source_endpoint": "HomeTimeline", // which GraphQL endpoint "captured_at": "2024-01-01T00:00:00.000Z" }