// ==UserScript==
// @name Twitter - Inline Follower Count
// @namespace https://github.com/digitalby
// @version 1.7.1
// @author digitalby
// @description Display follower count and bio directly in tweets
// @match https://twitter.com/*
// @match https://x.com/*
// @grant none
// @run-at document-start
// ==/UserScript==
(function () {
'use strict';
const CACHE_KEY = 'tm-follower-cache';
const CACHE_TTL = 7 * 24 * 60 * 60 * 1000; // 7 days
const RATE_WINDOW = 15 * 60 * 1000; // 15 minutes
const RATE_MAX = 1000; // max requests per window
const RATE_PAUSE = 15 * 60 * 1000; // pause duration on 429
const userCache = new Map(); // handle -> { followers, bio, ts }
let requestTimestamps = []; // timestamps of recent API calls
let ratePausedUntil = 0; // if > Date.now(), we're paused
// Load persisted cache from localStorage
try {
const stored = JSON.parse(localStorage.getItem(CACHE_KEY) || '{}');
const now = Date.now();
for (const [handle, entry] of Object.entries(stored)) {
if (entry.ts && (now - entry.ts) < CACHE_TTL) {
userCache.set(handle, entry);
}
}
console.log('[FollowerCount] Loaded', userCache.size, 'cached users from localStorage');
} catch {}
let saveTimer = null;
function persistCache() {
if (saveTimer) return;
saveTimer = setTimeout(() => {
saveTimer = null;
try {
const obj = Object.fromEntries(userCache);
localStorage.setItem(CACHE_KEY, JSON.stringify(obj));
} catch {}
}, 1000);
}
function formatCount(n) {
if (n >= 1e6) {
const val = n / 1e6;
return (val >= 10 ? Math.round(val) : val.toFixed(1).replace(/\.0$/, '')) + 'M';
}
if (n >= 1e3) {
const val = n / 1e3;
return (val >= 10 ? Math.round(val) : val.toFixed(1).replace(/\.0$/, '')) + 'K';
}
return String(n);
}
let reprocessTimer = null;
function scheduleReprocess() {
if (reprocessTimer) return;
reprocessTimer = setTimeout(() => {
reprocessTimer = null;
processAll();
}, 100);
}
// Dynamic GraphQL API fetch — discovers query ID from Twitter's own JS bundles
const BEARER = 'AAAAAAAAAAAAAAAAAAAAANRILgAAAAAAnNwIzUejRCOuH5E6I8xnZz4puTs%3D1Zv7ttfk8LF81IUq16cHjhLTvJu4FA33AGWWjCpTnA';
const fetchQueued = new Set();
const failedHandles = new Set();
let fetchQueue = [];
let fetchRunning = false;
let queueGeneration = 0; // incremented on cancellation
let consecutiveRequests = 0; // for escalating delay
let discoveredQueryId = null;
let discoveryPromise = null;
let capturedFeatures = null;
function getCsrfToken() {
const match = document.cookie.match(/(?:^|;\s*)ct0=([^;]+)/);
return match ? match[1] : '';
}
function randomDelay(min, max) {
return min + Math.random() * (max - min);
}
// Discover the UserByScreenName query ID from Twitter's loaded JS chunks
const QUERY_ID_PATTERNS = [
/queryId:"([^"]+)",operationName:"UserByScreenName"/,
/operationName:"UserByScreenName",queryId:"([^"]+)"/,
/queryId:"([^"]+)"[^}]{0,100}operationName:"UserByScreenName"/,
/operationName:"UserByScreenName"[^}]{0,100}queryId:"([^"]+)"/,
];
async function discoverQueryId() {
if (discoveredQueryId) return discoveredQueryId;
console.log('[FollowerCount] Starting query ID discovery...');
const scripts = document.querySelectorAll('script[src]');
console.log('[FollowerCount] Found', scripts.length, 'script tags to scan');
let scanned = 0, failed = 0;
for (const script of scripts) {
try {
const resp = await origFetch(script.src);
if (!resp.ok) { failed++; continue; }
const text = await resp.text();
scanned++;
for (const pattern of QUERY_ID_PATTERNS) {
const match = text.match(pattern);
if (match) {
discoveredQueryId = match[1];
console.log('[FollowerCount] Discovered queryId:', discoveredQueryId, 'from', script.src);
// Extract featureSwitches near the UserByScreenName definition
const idx = match.index;
const nearby = text.substring(idx, idx + 2000);
const featMatch = nearby.match(/featureSwitches:\[([^\]]+)\]/);
if (featMatch) {
const switches = featMatch[1].match(/"([^"]+)"/g).map(s => s.slice(1, -1));
const featObj = {};
switches.forEach(s => { featObj[s] = true; });
capturedFeatures = JSON.stringify(featObj);
console.log('[FollowerCount] Extracted', switches.length, 'features from bundle');
}
return discoveredQueryId;
}
}
} catch (e) {
failed++;
console.debug('[FollowerCount] Failed to fetch script:', script.src, e.message);
}
}
console.warn('[FollowerCount] Query ID not found. Scanned:', scanned, 'Failed:', failed);
return null;
}
// Retry discovery with delay (scripts may load late)
async function discoverQueryIdWithRetry() {
for (let attempt = 0; attempt < 3; attempt++) {
const id = await discoverQueryId();
if (id) return id;
console.log('[FollowerCount] Discovery attempt', attempt + 1, 'failed, retrying in', (attempt + 1) * 2, 's...');
await new Promise(r => setTimeout(r, (attempt + 1) * 2000));
discoveryPromise = null; // allow re-run
}
return null;
}
function cancelQueue() {
queueGeneration++;
fetchQueue = [];
fetchQueued.clear();
// Don't reset consecutiveRequests — the escalation must persist across queue rebuilds
}
function queueUserFetch(handle) {
if (fetchQueued.has(handle)) return;
// Skip if we already have a fresh cache entry
const cached = userCache.get(handle);
if (cached && cached.ts && (Date.now() - cached.ts) < CACHE_TTL) return;
fetchQueued.add(handle);
fetchQueue.push(handle);
if (!fetchRunning) drainFetchQueue();
}
function isRateLimited() {
const now = Date.now();
if (now < ratePausedUntil) return true;
requestTimestamps = requestTimestamps.filter(t => (now - t) < RATE_WINDOW);
return requestTimestamps.length >= RATE_MAX;
}
function drainFetchQueue() {
const gen = queueGeneration;
if (fetchRunning || fetchQueue.length === 0) return;
if (isRateLimited()) {
const retryIn = ratePausedUntil > Date.now()
? ratePausedUntil - Date.now()
: 30000;
console.log('[FollowerCount] Rate limited, retrying in', Math.round(retryIn / 1000), 's (' + fetchQueue.length, 'queued)');
setTimeout(() => { if (queueGeneration === gen) drainFetchQueue(); }, retryIn);
return;
}
fetchRunning = true;
const handle = fetchQueue.shift();
requestTimestamps.push(Date.now());
fetchUserByScreenName(handle).finally(() => {
fetchRunning = false;
if (queueGeneration !== gen) return;
// Escalating delay: 1-3s, 4-6s, 7-9s, 10-12s, ... no cap, no decay
const delay = randomDelay(1000 + consecutiveRequests * 3000, 3000 + consecutiveRequests * 3000);
consecutiveRequests++;
console.log('[FollowerCount] Next fetch in', Math.round(delay / 1000), 's (step', consecutiveRequests, ')');
setTimeout(() => { if (queueGeneration === gen) drainFetchQueue(); }, delay);
});
}
async function fetchUserByScreenName(screenName) {
try {
// Ensure we have the query ID (shared single discovery with retry)
if (!discoveredQueryId) {
if (!discoveryPromise) discoveryPromise = discoverQueryIdWithRetry();
await discoveryPromise;
}
if (!discoveredQueryId) {
console.warn('[FollowerCount] No queryId available, skipping fetch for', screenName);
fetchQueued.delete(screenName.toLowerCase()); // allow retry later
return;
}
if (!capturedFeatures) {
console.warn('[FollowerCount] No features available, skipping fetch for', screenName);
fetchQueued.delete(screenName.toLowerCase()); // allow retry later
return;
}
const variables = JSON.stringify({ screen_name: screenName, withSafetyModeUserFields: true });
const params = new URLSearchParams({ variables, features: capturedFeatures, fieldToggles: '{}' });
const url = `https://x.com/i/api/graphql/${discoveredQueryId}/UserByScreenName?${params}`;
const resp = await origFetch(url, {
headers: {
'authorization': `Bearer ${decodeURIComponent(BEARER)}`,
'x-csrf-token': getCsrfToken(),
'x-twitter-active-user': 'yes',
'x-twitter-auth-type': 'OAuth2Session',
},
credentials: 'include',
});
if (resp.status === 429) {
ratePausedUntil = Date.now() + RATE_PAUSE;
console.warn('[FollowerCount] Rate limited (429)! Pausing for 15 minutes.');
fetchQueued.delete(screenName.toLowerCase()); // allow retry later
fetchQueue.unshift(screenName); // put it back at the front
return;
}
if (!resp.ok) {
console.warn('[FollowerCount] API error for', screenName, resp.status);
failedHandles.add(screenName.toLowerCase());
scheduleReprocess();
return;
}
const json = await resp.json();
// Direct extraction — we already know the screenName, just find followers_count + bio
const result = json?.data?.user?.result;
const fc = result?.legacy?.followers_count
?? result?.followers_count
?? findFollowersCount(result);
const bio = result?.legacy?.description
?? result?.profile_bio?.description
?? findStringField(result, 'description');
if (typeof fc === 'number') {
cacheUser(screenName, fc, bio || '');
} else {
console.warn('[FollowerCount] Could not find followers_count for', screenName);
failedHandles.add(screenName.toLowerCase());
scheduleReprocess();
}
// Also run generic extraction for any other user data in the response
extractUsers(json, 0);
} catch (e) {
console.warn('[FollowerCount] Fetch failed for', screenName, e);
failedHandles.add(screenName.toLowerCase());
scheduleReprocess();
}
}
// Deep search for followers_count in an object
function findFollowersCount(obj, depth = 0) {
if (!obj || typeof obj !== 'object' || depth > 20) return null;
if (typeof obj.followers_count === 'number') return obj.followers_count;
for (const key of Object.keys(obj)) {
const val = obj[key];
if (val && typeof val === 'object') {
const found = findFollowersCount(val, depth + 1);
if (found !== null) return found;
}
}
return null;
}
// Deep search for a string field by name
function findStringField(obj, fieldName, depth = 0) {
if (!obj || typeof obj !== 'object' || depth > 10) return null;
if (typeof obj[fieldName] === 'string' && obj[fieldName].length > 0) return obj[fieldName];
for (const key of Object.keys(obj)) {
const val = obj[key];
if (val && typeof val === 'object' && !Array.isArray(val)) {
const found = findStringField(val, fieldName, depth + 1);
if (found) return found;
}
}
return null;
}
function cacheUser(screenName, followersCount, bio) {
const handle = screenName.toLowerCase();
const prev = userCache.get(handle);
const entry = { followers: followersCount, bio: bio ?? prev?.bio ?? '', ts: Date.now() };
userCache.set(handle, entry);
persistCache();
if (!prev) {
console.log('[FollowerCount] Cached:', handle, formatCount(followersCount));
scheduleReprocess();
}
}
function extractUsers(obj, depth) {
if (!obj || typeof obj !== 'object') return;
if (depth > 50) return;
// Standard legacy structure
if (obj.legacy && typeof obj.legacy.followers_count === 'number' && obj.legacy.screen_name) {
cacheUser(obj.legacy.screen_name, obj.legacy.followers_count);
}
// Alternative: screen_name and followers_count at the same level
if (typeof obj.screen_name === 'string' && typeof obj.followers_count === 'number') {
cacheUser(obj.screen_name, obj.followers_count);
}
for (const key of Object.keys(obj)) {
const val = obj[key];
if (Array.isArray(val)) {
val.forEach(item => extractUsers(item, depth + 1));
} else if (val && typeof val === 'object') {
extractUsers(val, depth + 1);
}
}
}
console.log('[FollowerCount] Script loaded, intercepting fetch/XHR');
const origFetch = window.fetch;
window.fetch = async function (...args) {
const resp = await origFetch.apply(this, args);
try {
const url = typeof args[0] === 'string' ? args[0] : args[0]?.url;
if (url && url.includes('/graphql/')) {
// Capture query IDs from Twitter's own requests
const qidMatch = url.match(/\/graphql\/([^/?]+)\/UserByScreenName/);
if (qidMatch && !discoveredQueryId) {
discoveredQueryId = qidMatch[1];
console.log('[FollowerCount] Captured UserByScreenName queryId from traffic:', discoveredQueryId);
}
}
if (url && (url.includes('/graphql/') || url.includes('/i/api/'))) {
const clone = resp.clone();
clone.json().then(json => {
try { extractUsers(json, 0); } catch (e) { console.warn('[FollowerCount] fetch parse error:', e); }
}).catch(() => {});
}
} catch {}
return resp;
};
const origOpen = XMLHttpRequest.prototype.open;
const origSend = XMLHttpRequest.prototype.send;
XMLHttpRequest.prototype.open = function (method, url, ...rest) {
this._tmUrl = url;
return origOpen.call(this, method, url, ...rest);
};
XMLHttpRequest.prototype.send = function (...args) {
if (this._tmUrl && (this._tmUrl.includes('/graphql/') || this._tmUrl.includes('/i/api/'))) {
this.addEventListener('load', function () {
try {
const json = JSON.parse(this.responseText);
extractUsers(json, 0);
} catch {}
});
}
return origSend.apply(this, args);
};
const BADGE_ATTR = 'data-follower-badge';
const STYLE_ID = 'tm-follower-count-style';
function injectStyles() {
if (document.getElementById(STYLE_ID)) return;
const style = document.createElement('style');
style.id = STYLE_ID;
style.textContent = `
.tm-follower-badge {
color: rgb(113, 118, 123);
font-size: 13px;
font-weight: 400;
white-space: nowrap;
display: inline-flex;
align-items: center;
}
.tm-follower-badge::before {
content: "·";
margin: 0 4px;
}
.tm-bio-line {
color: rgb(113, 118, 123);
font-size: 13px;
font-weight: 400;
line-height: 16px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 100%;
padding: 0 0 2px 0;
}
.tm-f-label {
font-style: italic;
opacity: 0.6;
}
.tm-usercell-badge {
color: rgb(113, 118, 123);
font-size: 13px;
font-weight: 400;
white-space: nowrap;
}
.tm-usercell-badge::before {
content: " · ";
}
`;
(document.head || document.documentElement).appendChild(style);
}
// Extract @handle from an element's descendant spans.
// Twitter handle spans contain exactly "@username" (1-15 word chars).
// We must skip display-name text that happens to contain "@" (e.g.
// "Yury @ yuryv.info" or "@user@mastodon.social").
const HANDLE_RE = /^@(\w{1,15})$/;
function findHandle(container) {
if (!container) return null;
const spans = container.querySelectorAll('span');
for (const span of spans) {
if (span.children.length !== 0) continue;
const m = span.textContent.trim().match(HANDLE_RE);
if (m) return m[1].toLowerCase();
}
return null;
}
// Extract handle from avatar data-testid (programmatic, immune to display name tricks)
function findHandleFromAvatar(container) {
const avatar = container.querySelector('[data-testid^="UserAvatar-Container-"]');
if (!avatar) return null;
const handle = avatar.getAttribute('data-testid').replace('UserAvatar-Container-', '');
return handle ? handle.toLowerCase() : null;
}
function processTweets() {
const articles = document.querySelectorAll('article[data-testid="tweet"]');
for (const article of articles) {
const prevState = article.getAttribute(BADGE_ATTR);
const userNameContainer = article.querySelector('[data-testid="User-Name"]');
// Prefer avatar data-testid (reliable), fall back to span scanning
let handle = findHandleFromAvatar(article) || findHandle(userNameContainer);
if (!handle) continue;
const cached = userCache.get(handle);
const failed = failedHandles.has(handle);
// Determine desired state
let state, badgeContent, bioText;
if (cached) {
state = 'loaded';
badgeContent = 'f\u200a' + formatCount(cached.followers);
bioText = cached.bio;
} else if (failed) {
state = 'error';
badgeContent = '!';
} else {
state = 'loading';
badgeContent = '\u2026'; // …
queueUserFetch(handle);
}
// Skip if already in this state
if (prevState === state + ':' + handle) continue;
// Remove old badge/bio if upgrading state
if (prevState) {
article.querySelectorAll('.tm-follower-badge').forEach(el => el.remove());
article.querySelectorAll('.tm-bio-line').forEach(el => el.remove());
}
const timeEl = article.querySelector('time');
if (!timeEl) continue;
const timeLink = timeEl.closest('a');
const container = timeLink ? timeLink.parentElement : timeEl.parentElement;
if (!container) continue;
article.setAttribute(BADGE_ATTR, state + ':' + handle);
const badge = document.createElement('span');
badge.className = 'tm-follower-badge';
badge.innerHTML = badgeContent;
container.appendChild(badge);
if (state === 'loaded' && bioText && userNameContainer) {
const bioEl = document.createElement('div');
bioEl.className = 'tm-bio-line';
bioEl.textContent = bioText.replace(/\n/g, ' ');
userNameContainer.parentElement.insertBefore(bioEl, userNameContainer.nextSibling);
}
}
}
function processUserCells() {
const cells = document.querySelectorAll('[data-testid="UserCell"]');
for (const cell of cells) {
const prevState = cell.getAttribute(BADGE_ATTR);
// Prefer avatar data-testid (reliable), fall back to span scanning
let handle = findHandleFromAvatar(cell) || findHandle(cell);
if (!handle) continue;
const cached = userCache.get(handle);
const failed = failedHandles.has(handle);
let state, badgeContent;
if (cached) {
state = 'loaded';
badgeContent = 'f\u200a' + formatCount(cached.followers);
} else if (failed) {
state = 'error';
badgeContent = '!';
} else {
state = 'loading';
badgeContent = '\u2026';
queueUserFetch(handle);
}
if (prevState === state + ':' + handle) continue;
if (prevState) {
cell.querySelectorAll('.tm-usercell-badge').forEach(el => el.remove());
}
cell.setAttribute(BADGE_ATTR, state + ':' + handle);
const spans = cell.querySelectorAll('span');
let handleSpan = null;
for (const span of spans) {
if (span.textContent.trim().toLowerCase() === '@' + handle && span.children.length === 0) {
handleSpan = span;
break;
}
}
if (handleSpan) {
const badge = document.createElement('span');
badge.className = 'tm-usercell-badge';
badge.innerHTML = badgeContent;
handleSpan.parentElement.appendChild(badge);
}
}
}
function processAll() {
injectStyles();
cancelQueue();
processTweets();
processUserCells();
}
function startObserver() {
processAll();
const observer = new MutationObserver(() => processAll());
observer.observe(document.body, { childList: true, subtree: true });
}
if (document.body) {
startObserver();
} else {
document.addEventListener('DOMContentLoaded', startObserver);
}
})();