-
-
Save tomsiwik/c076dffbd11af42e62062eb05d7a5e60 to your computer and use it in GitHub Desktop.
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
| // 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