Twitter Followers Mutual Tracker

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);

})();