Skip to content

Instantly share code, notes, and snippets.

@ahoward
Last active May 16, 2026 00:01
Show Gist options
  • Select an option

  • Save ahoward/89a8f11c9f276791e9c9edfbc0048c07 to your computer and use it in GitHub Desktop.

Select an option

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
// 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.');
})();
// 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);
})();
// 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.');
})();
// 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