您需要先安装一个扩展,例如 篡改猴、Greasemonkey 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 Userscripts ,之后才能安装此脚本。
您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。
您需要先安装用户脚本管理器扩展后才能安装此脚本。
Track mutual and non-mutual Twitter followers and download as JSON
// ==UserScript== // @name Twitter Followers Mutual Tracker // @description Track mutual and non-mutual Twitter followers and download as JSON // @namespace https://github.com/kaubu // @version 1.1.1 // @author kaubu (https://github.com/kaubu) // @match https://twitter.com/*/followers // @match https://twitter.com/*/following // @match https://x.com/*/followers // @match https://x.com/*/following // @grant none // @license 0BSD // ==/UserScript== (function() { 'use strict'; // Arrays to store mutuals and non-mutuals let mutuals = []; let nonMutuals = []; // Flag to track if the script is active let isTrackerActive = true; // Auto-scroll variables let isAutoScrolling = false; let autoScrollInterval; let lastUserCount = 0; let noNewUsersCounter = 0; // Helper: Extract display name function getDisplayName(cell) { // Try to find the display name in the most robust way // Twitter/X often uses the first span in the first link for display name const link = cell.querySelector('a[role="link"]:not([tabindex="-1"])'); if (link) { const span = link.querySelector('span'); if (span) return span.textContent.trim(); } // Fallback to previous selector const fallback = cell.querySelector('.css-1jxf684.r-bcqeeo.r-1ttztb7.r-qvutc0.r-poiln3:not([style*="display: none"])'); return fallback ? fallback.textContent.trim() : ''; } // Helper: Extract username function getUsername(cell) { // Try to find the username by looking for a span starting with '@' const spans = cell.querySelectorAll('span'); for (const span of spans) { if (span.textContent.trim().startsWith('@')) { return span.textContent.trim(); } } // Fallback to previous selector const fallback = cell.querySelector('[style*="color: rgb(113, 118, 123)"] .css-1jxf684.r-bcqeeo.r-1ttztb7.r-qvutc0.r-poiln3'); return fallback ? fallback.textContent.trim() : ''; } // Helper: Extract profile URL function getProfileUrl(cell, username) { // Try to find the first profile link const link = cell.querySelector('a[role="link"]:not([tabindex="-1"])'); if (link && link.getAttribute('href') && link.getAttribute('href').startsWith('/')) { return `https://x.com${link.getAttribute('href')}`; } // Fallback to constructing from username return username ? `https://x.com/${username.replace('@', '')}` : ''; } // Helper: Check if element is within "You might like" section function isInYouMightLikeSection(element) { // Check if this element or any parent has the "You might like" heading let current = element; const maxDepth = 10; // Prevent infinite loop let depth = 0; while (current && depth < maxDepth) { // Check if it's within a section with "You might like" heading const headingNearby = current.querySelector('h2 span'); if (headingNearby && headingNearby.textContent.includes('You might like')) { return true; } // Check if this is inside aside[aria-label="Who to follow"] const aside = current.closest('aside[aria-label="Who to follow"]'); if (aside) { return true; } current = current.parentElement; depth++; } return false; } // Function to start auto-scrolling function startAutoScroll() { if (isAutoScrolling) return; isAutoScrolling = true; noNewUsersCounter = 0; lastUserCount = mutuals.length + nonMutuals.length; document.getElementById('auto-scroll-btn').textContent = 'Stop Auto-Scroll'; autoScrollInterval = setInterval(() => { // Scroll down by a small amount window.scrollBy(0, 300); // Get current total users const currentUserCount = mutuals.length + nonMutuals.length; // Check if we've found new users if (currentUserCount > lastUserCount) { // Reset the counter if we found new users noNewUsersCounter = 0; lastUserCount = currentUserCount; } else { // Increment counter if no new users found noNewUsersCounter++; } // If we haven't found new users after several scrolls, consider it done if (noNewUsersCounter >= 10) { // Play beep sound playBeepSound(); // Stop auto-scrolling stopAutoScroll(); } }, 500); } // Function to stop auto-scrolling function stopAutoScroll() { if (!isAutoScrolling) return; isAutoScrolling = false; clearInterval(autoScrollInterval); document.getElementById('auto-scroll-btn').textContent = 'Auto-Scroll to Bottom'; } // Function to play a beep sound function playBeepSound() { const audioContext = new (window.AudioContext || window.webkitAudioContext)(); const oscillator = audioContext.createOscillator(); const gainNode = audioContext.createGain(); oscillator.type = 'sine'; oscillator.frequency.value = 220; // Lower A3 note for a deeper sound gainNode.gain.value = 0.1; // Low volume oscillator.connect(gainNode); gainNode.connect(audioContext.destination); oscillator.start(); setTimeout(() => { oscillator.stop(); }, 300); } // Function to close the tracker and cleanup function closeTracker() { // Stop auto-scrolling if active if (isAutoScrolling) { stopAutoScroll(); } // Remove the status div const statusDiv = document.getElementById('mutual-tracker-status'); if (statusDiv) { statusDiv.remove(); } // Set the tracker as inactive isTrackerActive = false; // Remove event listeners window.removeEventListener('scroll', processUserCells); window.removeEventListener('resize', processUserCells); } // Function to add the status div and buttons function addStatusDiv() { // Don't add if tracker is inactive if (!isTrackerActive) return; // Remove any existing status div const existingDiv = document.getElementById('mutual-tracker-status'); if (existingDiv) { existingDiv.remove(); } // Create the status div const statusDiv = document.createElement('div'); statusDiv.id = 'mutual-tracker-status'; statusDiv.style.position = 'fixed'; statusDiv.style.bottom = '20px'; statusDiv.style.right = '20px'; statusDiv.style.backgroundColor = '#1DA1F2'; statusDiv.style.color = 'white'; statusDiv.style.padding = '15px'; statusDiv.style.borderRadius = '8px'; statusDiv.style.zIndex = '10000'; statusDiv.style.fontSize = '14px'; statusDiv.style.fontFamily = 'Arial, sans-serif'; statusDiv.style.boxShadow = '0 2px 10px rgba(0,0,0,0.3)'; statusDiv.style.width = '220px'; statusDiv.style.maxWidth = '90vw'; statusDiv.style.boxSizing = 'border-box'; // Create header with close button const headerDiv = document.createElement('div'); headerDiv.style.display = 'flex'; headerDiv.style.justifyContent = 'space-between'; headerDiv.style.alignItems = 'center'; headerDiv.style.marginBottom = '12px'; const headerTitle = document.createElement('div'); headerTitle.textContent = 'Mutual Tracker'; headerTitle.style.fontWeight = 'bold'; const closeButton = document.createElement('div'); closeButton.textContent = '✕'; closeButton.style.cursor = 'pointer'; closeButton.style.fontSize = '16px'; closeButton.style.lineHeight = '16px'; closeButton.addEventListener('click', closeTracker); headerDiv.appendChild(headerTitle); headerDiv.appendChild(closeButton); statusDiv.appendChild(headerDiv); // Create the status text const statusText = document.createElement('div'); const totalAccounts = mutuals.length + nonMutuals.length; statusText.innerHTML = `Mutuals: ${mutuals.length} | Non-Mutuals: ${nonMutuals.length}<br>Total Accounts: ${totalAccounts}`; statusText.style.marginBottom = '12px'; statusDiv.appendChild(statusText); // Create button container for consistent styling const buttonContainer = document.createElement('div'); buttonContainer.style.display = 'flex'; buttonContainer.style.flexDirection = 'column'; buttonContainer.style.gap = '8px'; // Create the auto-scroll button const autoScrollButton = document.createElement('button'); autoScrollButton.id = 'auto-scroll-btn'; autoScrollButton.textContent = 'Auto-Scroll to Bottom'; autoScrollButton.style.padding = '8px 10px'; autoScrollButton.style.borderRadius = '4px'; autoScrollButton.style.border = 'none'; autoScrollButton.style.backgroundColor = 'white'; autoScrollButton.style.color = '#1DA1F2'; autoScrollButton.style.cursor = 'pointer'; autoScrollButton.style.fontWeight = 'bold'; autoScrollButton.style.width = '100%'; autoScrollButton.style.transition = 'background-color 0.2s'; autoScrollButton.addEventListener('mouseover', function() { this.style.backgroundColor = '#f0f0f0'; }); autoScrollButton.addEventListener('mouseout', function() { this.style.backgroundColor = 'white'; }); // Add event listener to auto-scroll button autoScrollButton.addEventListener('click', function() { if (isAutoScrolling) { stopAutoScroll(); } else { startAutoScroll(); } }); buttonContainer.appendChild(autoScrollButton); // Create the download button const downloadButton = document.createElement('button'); downloadButton.textContent = 'Download Data'; downloadButton.style.padding = '8px 10px'; downloadButton.style.borderRadius = '4px'; downloadButton.style.border = 'none'; downloadButton.style.backgroundColor = 'white'; downloadButton.style.color = '#1DA1F2'; downloadButton.style.cursor = 'pointer'; downloadButton.style.fontWeight = 'bold'; downloadButton.style.width = '100%'; downloadButton.style.transition = 'background-color 0.2s'; downloadButton.addEventListener('mouseover', function() { this.style.backgroundColor = '#f0f0f0'; }); downloadButton.addEventListener('mouseout', function() { this.style.backgroundColor = 'white'; }); // Add event listener to download button downloadButton.addEventListener('click', function() { downloadData(); }); buttonContainer.appendChild(downloadButton); statusDiv.appendChild(buttonContainer); document.body.appendChild(statusDiv); } // Function to download the data function downloadData() { const data = { mutuals: mutuals, nonMutuals: nonMutuals, totalMutuals: mutuals.length, totalNonMutuals: nonMutuals.length }; const dataStr = JSON.stringify(data, null, 2); const dataBlob = new Blob([dataStr], {type: 'application/json'}); const url = URL.createObjectURL(dataBlob); const a = document.createElement('a'); a.href = url; a.download = 'x_twitter_mutual_data.json'; a.click(); URL.revokeObjectURL(url); } // Function to check if an element is visible function isElementInViewport(el) { const rect = el.getBoundingClientRect(); return ( rect.top >= 0 && rect.left >= 0 && rect.bottom <= (window.innerHeight || document.documentElement.clientHeight) && rect.right <= (window.innerWidth || document.documentElement.clientWidth) ); } // Function to process user cells function processUserCells() { // Don't process if tracker is inactive if (!isTrackerActive) return; const userCells = document.querySelectorAll('[data-testid="UserCell"]'); userCells.forEach(cell => { // Skip if already processed if (cell.dataset.processed === 'true') { return; } // Only process visible cells if (!isElementInViewport(cell)) { return; } // Skip if in "You might like" section if (isInYouMightLikeSection(cell)) { cell.dataset.processed = 'true'; return; } try { // Get display name const displayName = getDisplayName(cell); // Get username const username = getUsername(cell); // Get URL const url = getProfileUrl(cell, username); // Check if mutual (has "Follows you" indicator) const followsYouIndicator = cell.querySelector('[data-testid="userFollowIndicator"]'); // Create user object const userObject = { displayName: displayName, username: username, url: url }; // Add to appropriate array if (username) { if (followsYouIndicator && !mutuals.some(m => m.username === username)) { mutuals.push(userObject); } else if (!followsYouIndicator && !nonMutuals.some(nm => nm.username === username)) { nonMutuals.push(userObject); } } // Mark as processed cell.dataset.processed = 'true'; // Update status addStatusDiv(); } catch (error) { console.error('Error processing user cell:', error); } }); } // Function to initialize the observer function initObserver() { const observer = new MutationObserver(function() { if (isTrackerActive) { processUserCells(); } }); observer.observe(document.body, { childList: true, subtree: true }); // Initial processing processUserCells(); // Add status initially addStatusDiv(); } // Initialize when page is loaded window.addEventListener('load', function() { setTimeout(initObserver, 1500); }); // Also process on scroll and resize (for dynamic content) window.addEventListener('scroll', processUserCells); window.addEventListener('resize', processUserCells); })();