Last active
May 16, 2026 00:01
-
-
Save ahoward/89a8f11c9f276791e9c9edfbc0048c07 to your computer and use it in GitHub Desktop.
X.com profile capture — auto-scroll then snapshot DOM+JSON; for litigation evidence preservation
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| // X profile / page auto-scroller. | |
| // Paste into DevTools console on x.com/<handle> (or /with_replies, /media, etc.). | |
| // Run this FIRST. When you see "done", paste the capture script. | |
| (async () => { | |
| console.log('autoscroll: starting — do not interact with the page'); | |
| let lastHeight = 0, stable = 0, passes = 0; | |
| while (stable < 5 && passes < 400) { | |
| window.scrollTo(0, document.documentElement.scrollHeight); | |
| await new Promise(r => setTimeout(r, 1500)); | |
| const h = document.documentElement.scrollHeight; | |
| if (h === lastHeight) stable++; else { stable = 0; lastHeight = h; } | |
| passes++; | |
| if (passes % 10 === 0) console.log(`autoscroll: pass ${passes}, height ${h}, stable ${stable}/5`); | |
| } | |
| console.log(`autoscroll: reached bottom in ${passes} passes (height ${lastHeight})`); | |
| window.scrollTo(0, 0); | |
| await new Promise(r => setTimeout(r, 2000)); | |
| console.log('autoscroll: done. Now paste the capture script.'); | |
| })(); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| // X page capture. | |
| // Paste into DevTools console AFTER the autoscroller has finished. | |
| // Downloads two files to ~/Downloads: | |
| // xcapture-<timestamp>-<slug>.html — rendered DOM | |
| // xcapture-<timestamp>-<slug>.json — structured tweet data | |
| // Repeat for x.com/<handle>, /with_replies, /media as needed. | |
| (async () => { | |
| const html = '<!DOCTYPE html>\n<!-- captured ' + new Date().toISOString() | |
| + ' from ' + location.href + ' -->\n' + document.documentElement.outerHTML; | |
| const tweets = []; | |
| document.querySelectorAll('article[data-testid="tweet"]').forEach(a => { | |
| const t = {}; | |
| const statusLink = a.querySelector('a[href*="/status/"]'); | |
| if (statusLink) { | |
| const m = statusLink.href.match(/status\/(\d+)/); | |
| if (m) t.id = m[1]; | |
| t.url = statusLink.href; | |
| } | |
| const userLink = a.querySelector('a[href^="/"][role="link"]'); | |
| if (userLink) t.author = userLink.getAttribute('href').replace('/', ''); | |
| const time = a.querySelector('time'); | |
| if (time) { | |
| t.datetime = time.getAttribute('datetime'); | |
| t.displayTime = time.textContent; | |
| } | |
| const text = a.querySelector('[data-testid="tweetText"]'); | |
| if (text) t.text = text.innerText; | |
| ['reply', 'retweet', 'like'].forEach(k => { | |
| const el = a.querySelector('[data-testid="' + k + '"]'); | |
| if (el) t[k + 'Count'] = el.innerText || '0'; | |
| }); | |
| const imgs = [...a.querySelectorAll('img[src*="pbs.twimg.com"]')].map(i => i.src); | |
| if (imgs.length) t.images = imgs; | |
| tweets.push(t); | |
| }); | |
| const meta = { | |
| capturedAt: new Date().toISOString(), | |
| url: location.href, | |
| userAgent: navigator.userAgent, | |
| scrollHeight: document.documentElement.scrollHeight, | |
| viewportHeight: window.innerHeight, | |
| tweetCount: tweets.length, | |
| domSizeBytes: html.length, | |
| }; | |
| const json = JSON.stringify({ meta, tweets }, null, 2); | |
| const stamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19); | |
| const slug = location.pathname.replace(/\//g, '_').replace(/^_/, '') || 'root'; | |
| const base = 'xcapture-' + stamp + '-' + slug; | |
| function dl(name, content, mime) { | |
| const blob = new Blob([content], { type: mime }); | |
| const url = URL.createObjectURL(blob); | |
| const a = document.createElement('a'); | |
| a.href = url; a.download = name; | |
| document.body.appendChild(a); a.click(); | |
| setTimeout(() => { URL.revokeObjectURL(url); a.remove(); }, 100); | |
| } | |
| dl(base + '.html', html, 'text/html'); | |
| await new Promise(r => setTimeout(r, 500)); | |
| dl(base + '.json', json, 'application/json'); | |
| console.log('capture: done — ' + tweets.length + ' tweets'); | |
| console.log('capture: files:', base + '.html', '+', base + '.json'); | |
| console.log('capture: meta:', meta); | |
| })(); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| // X.com GraphQL interceptor — forensic-grade tweet capture. | |
| // | |
| // HOW TO USE: | |
| // 1. Open x.com/<handle> (or /with_replies, /media — whatever URL you want | |
| // to capture). | |
| // 2. Open DevTools (F12) → Console. | |
| // 3. Paste this whole script. You'll see "interceptor armed". | |
| // 4. Slowly scroll the page to the bottom. Take your time. Let each chunk load. | |
| // The console will log "captured N tweets so far" after each X API call. | |
| // 5. When you're done scrolling, type: dumpXCapture() | |
| // A JSON file downloads with every tweet captured. | |
| // 6. (Optional) Type: resetXCapture() to clear and start over. | |
| // | |
| // What this captures: every X GraphQL response that contains tweets, with | |
| // tweet IDs, ISO timestamps, full text, author handles, image/video URLs, | |
| // engagement counts, conversation context. This is the raw data X sent | |
| // your browser — the strongest form of evidence short of subpoenaing X. | |
| (() => { | |
| if (window.__xCaptureInstalled) { | |
| console.log('x-capture: already installed. Use dumpXCapture() or resetXCapture().'); | |
| return; | |
| } | |
| window.__xCaptureInstalled = true; | |
| // Patterns of X GraphQL endpoints that return tweet data. | |
| const ENDPOINT_PATTERNS = [ | |
| /\/i\/api\/graphql\/[^/]+\/UserTweets/, | |
| /\/i\/api\/graphql\/[^/]+\/UserTweetsAndReplies/, | |
| /\/i\/api\/graphql\/[^/]+\/UserMedia/, | |
| /\/i\/api\/graphql\/[^/]+\/Likes/, | |
| /\/i\/api\/graphql\/[^/]+\/TweetDetail/, | |
| /\/i\/api\/graphql\/[^/]+\/SearchTimeline/, | |
| /\/i\/api\/graphql\/[^/]+\/Followers/, | |
| /\/i\/api\/graphql\/[^/]+\/Following/, | |
| /\/i\/api\/2\/timeline\//, | |
| ]; | |
| // In-memory captures, keyed by request URL + timestamp so dups are visible | |
| // rather than silently merged. The dump groups by URL pattern. | |
| window.__xCapture = { | |
| startedAt: new Date().toISOString(), | |
| pageUrl: location.href, | |
| pageTitle: document.title, | |
| userAgent: navigator.userAgent, | |
| captures: [], | |
| }; | |
| const origFetch = window.fetch; | |
| window.fetch = async function (...args) { | |
| const req = args[0]; | |
| const url = typeof req === 'string' ? req : (req && req.url) || ''; | |
| const isInteresting = ENDPOINT_PATTERNS.some(p => p.test(url)); | |
| const response = await origFetch.apply(this, args); | |
| if (isInteresting) { | |
| try { | |
| const clone = response.clone(); | |
| clone.text().then(body => { | |
| try { | |
| const json = JSON.parse(body); | |
| window.__xCapture.captures.push({ | |
| capturedAt: new Date().toISOString(), | |
| url, | |
| status: response.status, | |
| responseHeaders: Object.fromEntries(response.headers.entries()), | |
| json, | |
| }); | |
| const tweetCount = countTweets(json); | |
| console.log( | |
| 'x-capture: +' + tweetCount + ' tweets from ' + | |
| url.replace(/^.*\/graphql\/[^/]+\//, '').slice(0, 80) + | |
| ' (total captures: ' + window.__xCapture.captures.length + ')' | |
| ); | |
| } catch (e) { | |
| window.__xCapture.captures.push({ | |
| capturedAt: new Date().toISOString(), | |
| url, | |
| status: response.status, | |
| rawBody: body, | |
| parseError: String(e), | |
| }); | |
| } | |
| }).catch(e => console.warn('x-capture: clone-read failed', e)); | |
| } catch (e) { | |
| console.warn('x-capture: response clone failed for', url, e); | |
| } | |
| } | |
| return response; | |
| }; | |
| // Best-effort tweet-counter for log readability. The GraphQL shapes vary, | |
| // so this is heuristic. | |
| function countTweets(json) { | |
| const s = JSON.stringify(json); | |
| return (s.match(/"__typename":"Tweet"/g) || []).length || | |
| (s.match(/"rest_id":"\d+"/g) || []).length; | |
| } | |
| window.dumpXCapture = function () { | |
| const data = window.__xCapture; | |
| const totalTweets = data.captures.reduce( | |
| (n, c) => n + (c.json ? countTweets(c.json) : 0), 0 | |
| ); | |
| data.dumpedAt = new Date().toISOString(); | |
| data.totalTweetCount = totalTweets; | |
| const json = JSON.stringify(data, null, 2); | |
| const stamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19); | |
| const slug = (location.pathname.replace(/\//g, '_').replace(/^_/, '') || 'root'); | |
| const name = 'xgraphql-' + stamp + '-' + slug + '.json'; | |
| const blob = new Blob([json], { type: 'application/json' }); | |
| const u = URL.createObjectURL(blob); | |
| const a = document.createElement('a'); | |
| a.href = u; a.download = name; | |
| document.body.appendChild(a); a.click(); | |
| setTimeout(() => { URL.revokeObjectURL(u); a.remove(); }, 200); | |
| console.log('x-capture: dumped ' + data.captures.length + | |
| ' API responses, ~' + totalTweets + ' tweets, to ' + name); | |
| return data; | |
| }; | |
| window.resetXCapture = function () { | |
| window.__xCapture = { | |
| startedAt: new Date().toISOString(), | |
| pageUrl: location.href, | |
| pageTitle: document.title, | |
| userAgent: navigator.userAgent, | |
| captures: [], | |
| }; | |
| console.log('x-capture: reset.'); | |
| }; | |
| console.log('x-capture: interceptor armed. Scroll the page. ' + | |
| 'When done, run dumpXCapture() to download. ' + | |
| 'Use resetXCapture() to clear.'); | |
| })(); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| // X.com slow-scroll for screen-recording capture. | |
| // | |
| // Pairs with an external screen recorder (ffmpeg / OBS / peek). | |
| // | |
| // HOW TO USE: | |
| // 1. Start your screen recorder (see chat). | |
| // 2. Open x.com/<handle> (or /with_replies, /media). | |
| // 3. Maximize browser. Hide DevTools or float it out of the way so it | |
| // doesn't cover the content. | |
| // 4. Paste this script into the Console. | |
| // 5. The page scrolls in small steps with a dwell time at each step, | |
| // giving the recorder time to catch each frame and X time to lazy-load. | |
| // 6. When done, console prints "slow-scroll: complete". Stop the recorder. | |
| // 7. Repeat for /with_replies and /media if needed. | |
| // | |
| // TUNABLES (edit before pasting if you want different timing): | |
| const STEP_PX = 400; // pixels per scroll | |
| const DWELL_MS = 1500; // ms paused after each step (lets X load + recorder catch) | |
| const MAX_PASSES = 2000; // safety cap | |
| const STABLE_NEED = 8; // how many no-growth passes before we declare done | |
| (async () => { | |
| console.log('slow-scroll: starting in 3 seconds — start your recorder NOW'); | |
| await new Promise(r => setTimeout(r, 3000)); | |
| console.log('slow-scroll: scrolling top -> bottom'); | |
| // top | |
| window.scrollTo({ top: 0, behavior: 'instant' }); | |
| await new Promise(r => setTimeout(r, DWELL_MS)); | |
| let lastH = 0, stable = 0, passes = 0, y = 0; | |
| while (passes < MAX_PASSES && stable < STABLE_NEED) { | |
| y += STEP_PX; | |
| window.scrollTo({ top: y, behavior: 'instant' }); | |
| await new Promise(r => setTimeout(r, DWELL_MS)); | |
| const h = document.documentElement.scrollHeight; | |
| if (y >= h - window.innerHeight) { | |
| if (h === lastH) stable++; else { stable = 0; lastH = h; } | |
| } | |
| passes++; | |
| if (passes % 10 === 0) { | |
| console.log('slow-scroll: pass ' + passes + ', y=' + y + ', height=' + h + ', stable=' + stable + '/' + STABLE_NEED); | |
| } | |
| } | |
| console.log('slow-scroll: reached bottom in ' + passes + ' passes. Final height ' + lastH); | |
| console.log('slow-scroll: complete. Stop the recorder.'); | |
| })(); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment