Skip to content

Instantly share code, notes, and snippets.

@tomsiwik
Created November 13, 2025 15:25
Show Gist options
  • Select an option

  • Save tomsiwik/c076dffbd11af42e62062eb05d7a5e60 to your computer and use it in GitHub Desktop.

Select an option

Save tomsiwik/c076dffbd11af42e62062eb05d7a5e60 to your computer and use it in GitHub Desktop.
// Twitter/X Post Stats Logger
// This script monitors for new posts and logs their statistics
const loggedPosts = new Set();
let languageFilter = 'en'; // Default to English only
let maxAgeHours = 6; // Default to 6 hours maximum age
// Opacity formula parameters (adjustable)
let opacityConfig = {
k: 0.00012, // Steepness of curve (increased for more sensitivity)
midpoint: 30000, // Impressions/hour for ~0.55 opacity (lowered from 40k)
minOpacity: 0.1, // Minimum opacity
maxOpacity: 1.0 // Maximum opacity
};
// Filter styling parameters
let filterConfig = {
grayscaleStrength: 100, // 0-100, percentage of grayscale for filtered posts
filteredOpacity: 0.5 // Opacity for filtered posts
};
function shouldHidePost(article) {
const tweetTextElement = article.querySelector('[data-testid="tweetText"]');
const language = tweetTextElement?.getAttribute('lang');
// Only hide if language doesn't match filter
if (languageFilter && language && language !== languageFilter) {
return true;
}
return false;
}
function isRepost(article) {
// Check for repost indicator - looks for "reposted" text in social context
const socialContext = article.querySelector('[data-testid="socialContext"]');
if (socialContext && socialContext.textContent.toLowerCase().includes('repost')) {
return true;
}
// Also check for retweet icon in the header
const retweetIcon = article.querySelector('svg path[d*="M4.5 3.88l4.432 4.14"]');
return !!retweetIcon;
}
function calculateOpacity(impressions, ageHours) {
// Base opacity on impression-to-time ratio
// Using a sigmoid-like curve for smooth transitions
// Example: 1M impressions in 12h = ratio of ~83,333/hour → opacity 1.0
// 100K impressions in 12h = ratio of ~8,333/hour → opacity 0.1
const impressionsPerHour = impressions / Math.max(ageHours, 0.1); // Avoid division by zero
// Sigmoid function: opacity = min + (max - min) / (1 + e^(-k * (x - midpoint)))
const k = opacityConfig.k;
const midpoint = opacityConfig.midpoint;
const minOpacity = opacityConfig.minOpacity;
const maxOpacity = opacityConfig.maxOpacity;
const sigmoid = 1 / (1 + Math.exp(-k * (impressionsPerHour - midpoint)));
const opacity = minOpacity + ((maxOpacity - minOpacity) * sigmoid);
return Math.max(minOpacity, Math.min(maxOpacity, opacity));
}
function calculateGrayscale(ageHours, impressions, replies) {
// No grayscale for posts < 6 hours
if (ageHours < 6) return 0;
// Base grayscale on age with faster progression after 16h
let grayscale = 0;
if (ageHours >= 6 && ageHours < 12) {
// Linear progression from 0% to 40% between 6-12 hours
grayscale = ((ageHours - 6) / 6) * 40;
} else if (ageHours >= 12 && ageHours < 16) {
// Linear progression from 40% to 70% between 12-16 hours
grayscale = 40 + ((ageHours - 12) / 4) * 30;
} else if (ageHours >= 16 && ageHours < 24) {
// Steeper progression from 70% to 100% between 16-24 hours
grayscale = 70 + ((ageHours - 16) / 8) * 30;
} else {
// 24+ hours = full grayscale
grayscale = 100;
}
// Viral factor reduces grayscale (better engagement = less gray)
const impressionsPerHour = impressions / Math.max(ageHours, 0.1);
const viralityReduction = Math.min(100, (impressionsPerHour / 1000)); // Every 1k impr/hr reduces gray by 1%
grayscale = Math.max(0, grayscale - viralityReduction);
// Comment-to-impression ratio (opportunity factor)
// Low ratio = low competition = less grayscale
// Example: 10 comments / 1M impressions = 0.00001 ratio (very low, reduce grayscale)
// 1000 comments / 100K impressions = 0.01 ratio (high, no reduction)
if (impressions > 0) {
const commentRatio = replies / impressions;
// If ratio < 0.001 (less than 1 comment per 1000 impressions), reduce grayscale
// Scale: 0.001 ratio = 0% reduction, 0 ratio = 30% reduction
const commentReduction = Math.max(0, Math.min(30, (0.001 - commentRatio) * 30000));
grayscale = Math.max(0, grayscale - commentReduction);
}
return Math.min(100, Math.max(0, grayscale));
}
function stylePost(article) {
const shouldFilter = shouldHidePost(article);
if (shouldFilter) {
// Non-English posts: full grayscale and minimum opacity
article.style.filter = `grayscale(100%)`;
article.style.opacity = opacityConfig.minOpacity.toString();
article.setAttribute('data-filtered-by-rules', 'true');
return;
}
// Check if this is a repost
const repost = isRepost(article);
// Extract engagement metrics
const timeElement = article.querySelector('time');
const datetime = timeElement?.getAttribute('datetime');
const viewsLink = article.querySelector('a[href*="/analytics"]');
const replyButton = article.querySelector('[data-testid="reply"]');
const impressions = extractNumber(viewsLink);
const replies = extractNumber(replyButton);
if (datetime) {
const postDate = new Date(datetime);
const now = new Date();
const ageHours = (now - postDate) / 3600000;
// Reposts older than 12 hours get heavily penalized
if (repost && ageHours > 12) {
const grayscale = 100; // Full grayscale
const opacity = opacityConfig.minOpacity; // Minimum opacity
article.style.opacity = opacity.toString();
article.style.filter = `grayscale(${grayscale}%)`;
article.setAttribute('data-filtered-by-rules', 'repost');
return;
}
// Calculate opacity based on engagement
let opacity = 1.0;
if (impressions > 0) {
opacity = calculateOpacity(impressions, ageHours);
}
// Additional opacity penalty for posts >16 hours
if (ageHours > 16) {
const agePenalty = Math.min(0.5, (ageHours - 16) / 16); // Up to 50% opacity reduction
opacity = opacity * (1 - agePenalty);
}
// Calculate grayscale based on age, virality, and comments
const grayscale = calculateGrayscale(ageHours, impressions, replies);
article.style.opacity = opacity.toFixed(2);
article.style.filter = grayscale > 0 ? `grayscale(${grayscale.toFixed(0)}%)` : 'none';
article.removeAttribute('data-filtered-by-rules');
} else {
// No time data, show normally
article.style.opacity = '1';
article.style.filter = 'none';
article.removeAttribute('data-filtered-by-rules');
}
}
function applyFilters() {
const articles = document.querySelectorAll('article[data-testid="tweet"]');
articles.forEach(article => {
stylePost(article);
});
}
function parsePostStats(article) {
try {
// Apply styling based on filters and engagement
stylePost(article);
// Extract post ID from status link
const statusLink = article.querySelector('a[href*="/status/"]');
const tweetId = statusLink?.href?.match(/status\/(\d+)/)?.[1];
if (!tweetId || loggedPosts.has(tweetId)) {
return null;
}
// Extract user information from User-Name testid
const userNameElement = article.querySelector('[data-testid="User-Name"]');
// Extract user ID from profile image URL
const profileImg = article.querySelector('img[src*="profile_images"]');
const userIdMatch = profileImg?.src?.match(/profile_images\/(\d+)\//);
const userId = userIdMatch?.[1] || 'Unknown';
// Display name - first link's text content (before the username)
const userLinks = userNameElement?.querySelectorAll('a[role="link"]') || [];
let displayName = 'Unknown';
let username = 'Unknown';
if (userLinks.length > 0) {
// First link contains display name
const displayText = userLinks[0].textContent?.trim();
// Second link typically contains @username
const usernameText = userLinks[1]?.textContent?.trim();
if (displayText && usernameText) {
// Display name is the text before the @username part
displayName = displayText.replace(usernameText, '').trim();
username = usernameText.replace('@', '').trim();
} else {
// Fallback: try to find spans with @ prefix
const allSpans = Array.from(userNameElement?.querySelectorAll('span') || []);
const usernameSpan = allSpans.find(span => span.textContent?.startsWith('@'));
if (usernameSpan) {
username = usernameSpan.textContent.replace('@', '').trim();
// Display name is likely in a previous span
const displaySpan = allSpans.find(span =>
!span.textContent?.startsWith('@') &&
span.textContent?.length > 0 &&
span !== usernameSpan
);
if (displaySpan) displayName = displaySpan.textContent.trim();
}
}
}
// Verified badge - check for SVG with aria-label containing "Verified"
const isVerified = !!userNameElement?.querySelector('svg[aria-label*="Verified"]');
// Extract post age from time element
const timeElement = article.querySelector('time');
const datetime = timeElement?.getAttribute('datetime');
const postAge = datetime ? calculateAge(datetime) : 'Unknown';
// Extract language from tweetText
const tweetTextElement = article.querySelector('[data-testid="tweetText"]');
const language = tweetTextElement?.getAttribute('lang') || 'Unknown';
// Extract engagement metrics from buttons
const replyButton = article.querySelector('[data-testid="reply"]');
const retweetButton = article.querySelector('[data-testid="retweet"]');
const likeButton = article.querySelector('[data-testid="like"]');
const viewsLink = article.querySelector('a[href*="/analytics"]');
const replies = extractNumber(replyButton);
const reposts = extractNumber(retweetButton);
const likes = extractNumber(likeButton);
const impressions = extractNumber(viewsLink);
const stats = {
postId: tweetId,
user: {
userId,
displayName,
username,
verified: isVerified
},
language,
postAge,
engagement: {
replies,
reposts,
likes,
impressions
},
timestamp: new Date().toISOString()
};
loggedPosts.add(tweetId);
return stats;
} catch (error) {
return null;
}
}
function extractNumber(element) {
if (!element) return 0;
const text = element.textContent.trim();
if (!text || text === '') return 0;
// Handle K (thousands) and M (millions) suffixes
const match = text.match(/([\d.]+)([KM]?)/i);
if (!match) return 0;
const number = parseFloat(match[1]);
const suffix = match[2]?.toUpperCase();
if (suffix === 'K') return Math.round(number * 1000);
if (suffix === 'M') return Math.round(number * 1000000);
return Math.round(number);
}
function calculateAge(datetime) {
const postDate = new Date(datetime);
const now = new Date();
const diffMs = now - postDate;
const diffMins = Math.floor(diffMs / 60000);
const diffHours = Math.floor(diffMs / 3600000);
const diffDays = Math.floor(diffMs / 86400000);
if (diffMins < 60) {
return `${diffMins}m ago`;
} else if (diffHours < 24) {
return `${diffHours}h ago`;
} else if (diffDays < 7) {
return `${diffDays}d ago`;
} else {
return postDate.toLocaleDateString();
}
}
function processExistingPosts() {
const articles = document.querySelectorAll('article[data-testid="tweet"]');
articles.forEach(article => {
const stats = parsePostStats(article);
if (stats) {
console.log(stats);
}
});
}
function startMonitoring() {
// Process existing posts
processExistingPosts();
// Create observer for new posts
const observer = new MutationObserver((mutations) => {
mutations.forEach((mutation) => {
mutation.addedNodes.forEach((node) => {
if (node.nodeType === 1) {
let article;
if (node.matches?.('article[data-testid="tweet"]')) {
article = node;
} else {
article = node.querySelector?.('article[data-testid="tweet"]');
}
if (article) {
setTimeout(() => {
const stats = parsePostStats(article);
if (stats) {
console.log(stats);
}
}, 100);
}
}
});
});
});
// Start observing
let timeline = document.querySelector('[aria-label*="Timeline"]') ||
document.querySelector('[data-testid="primaryColumn"]') ||
document.querySelector('main[role="main"]') ||
document.body;
observer.observe(timeline, {
childList: true,
subtree: true
});
return observer;
}
// Auto-start
const observer = startMonitoring();
// Expose utilities for manual control
window.twitterStatsLogger = {
stop: () => {
observer.disconnect();
},
restart: () => {
observer.disconnect();
loggedPosts.clear();
return startMonitoring();
},
clearCache: () => {
loggedPosts.clear();
},
getLoggedCount: () => {
return loggedPosts.size;
},
setLanguageFilter: (lang) => {
if (lang === null || lang === '') {
languageFilter = null;
} else {
languageFilter = lang;
}
applyFilters();
},
getLanguageFilter: () => {
return languageFilter;
},
setMaxAge: (hours) => {
maxAgeHours = hours;
applyFilters();
},
getMaxAge: () => {
return maxAgeHours;
},
showAllPosts: () => {
const articles = document.querySelectorAll('article[data-testid="tweet"]');
articles.forEach(article => {
article.style.opacity = '1';
article.style.filter = 'none';
article.removeAttribute('data-filtered-by-rules');
});
languageFilter = null;
maxAgeHours = null;
},
setOpacityConfig: (config) => {
// Update opacity formula parameters
// config can include: k, midpoint, minOpacity, maxOpacity
Object.assign(opacityConfig, config);
applyFilters(); // Re-apply with new settings
},
getOpacityConfig: () => {
return { ...opacityConfig };
},
setFilterConfig: (config) => {
// Update filter styling parameters
// config can include: grayscaleStrength (0-100), filteredOpacity (0-1)
Object.assign(filterConfig, config);
applyFilters(); // Re-apply with new settings
},
getFilterConfig: () => {
return { ...filterConfig };
},
testOpacity: (impressions, ageHours) => {
// Helper to test opacity calculation
const opacity = calculateOpacity(impressions, ageHours);
const impressionsPerHour = impressions / ageHours;
console.log(`Impressions: ${impressions}, Age: ${ageHours}h`);
console.log(`Rate: ${impressionsPerHour.toFixed(0)}/hour`);
console.log(`Opacity: ${opacity.toFixed(3)}`);
return opacity;
},
debug: () => {
const articles = document.querySelectorAll('article[data-testid="tweet"]');
console.log('🔍 Debug Information:');
console.log(` Total articles found: ${articles.length}`);
console.log(` Already logged: ${loggedPosts.size}`);
console.log(` Language filter: ${languageFilter || 'none'}`);
console.log(` Max age filter: ${maxAgeHours !== null ? maxAgeHours + 'h' : 'none'}`);
if (articles.length > 0) {
console.log('\n First article structure:');
const first = articles[0];
const statusLink = first.querySelector('a[href*="/status/"]');
const userNameElement = first.querySelector('[data-testid="User-Name"]');
const timeElement = first.querySelector('time');
console.log(' - Has User-Name testid:', !!userNameElement);
console.log(' - Has time element:', !!timeElement);
console.log(' - Time datetime:', timeElement?.getAttribute('datetime'));
console.log(' - Has reply button:', !!first.querySelector('[data-testid="reply"]'));
console.log(' - Status link:', statusLink?.href);
console.log(' - Extracted ID:', statusLink?.href?.match(/status\/(\d+)/)?.[1]);
const tweetTextElement = first.querySelector('[data-testid="tweetText"]');
console.log(' - Tweet language:', tweetTextElement?.getAttribute('lang'));
// Show what we're extracting from User-Name
if (userNameElement) {
console.log('\n User-Name element analysis:');
const profileImg = first.querySelector('img[src*="profile_images"]');
console.log(' - Profile image URL:', profileImg?.src);
const userIdMatch = profileImg?.src?.match(/profile_images\/(\d+)\//);
console.log(' - Extracted user ID:', userIdMatch?.[1]);
const userLinks = userNameElement.querySelectorAll('a[role="link"]');
console.log(' - Number of user links:', userLinks.length);
userLinks.forEach((link, i) => {
console.log(` - Link ${i} text:`, link.textContent?.trim());
});
const allSpans = Array.from(userNameElement.querySelectorAll('span'));
const relevantSpans = allSpans.filter(s => s.textContent?.trim().length > 0 && s.textContent?.trim().length < 100);
console.log(' - Relevant spans:', relevantSpans.map(s => s.textContent?.trim()));
console.log(' - Has verified SVG:', !!userNameElement.querySelector('svg[aria-label*="Verified"]'));
}
// Show engagement metrics
console.log('\n Engagement metrics raw text:');
const replyBtn = first.querySelector('[data-testid="reply"]');
const retweetBtn = first.querySelector('[data-testid="retweet"]');
const likeBtn = first.querySelector('[data-testid="like"]');
const viewsLink = first.querySelector('a[href*="/analytics"]');
console.log(' - Reply button text:', replyBtn?.textContent?.trim());
console.log(' - Retweet button text:', retweetBtn?.textContent?.trim());
console.log(' - Like button text:', likeBtn?.textContent?.trim());
console.log(' - Views link text:', viewsLink?.textContent?.trim());
console.log('\n Attempting to parse first post:');
// Temporarily remove from logged posts to test
const testId = statusLink?.href?.match(/status\/(\d+)/)?.[1];
if (testId) loggedPosts.delete(testId);
const stats = parsePostStats(first);
console.log(stats);
if (stats) {
console.log('\n✅ Parsing successful!');
} else {
console.log('\n❌ Parsing failed - check error messages above');
}
}
return { articleCount: articles.length, loggedCount: loggedPosts.size };
},
forceProcess: () => {
loggedPosts.clear();
processExistingPosts();
}
};
console.log('Ready');
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment