Character.AI Follower Tracker

Popup to show who unfollowed you on Character.AI!

// ==UserScript==
// @name         Character.AI Follower Tracker
// @namespace    http://tampermonkey.net/
// @icon         https://www.google.com/s2/favicons?sz=64&domain=character.ai
// @version      1.8
// @description  Popup to show who unfollowed you on Character.AI!
// @author       Kio + Claude + Gemini 💗
// @match        https://character.ai/*
// @match *://character.ai/*// 
// @grant        none
// @license MIT
// ==/UserScript==

(function() {
    'use strict';

    // --- S T Y L E S ---
    const styleSheet = `
        @import url('https://fonts.googleapis.com/css2?family=Ubuntu:wght@400;700&display=swap');

        .cai-tracker-panel, .cai-results-popup {
            position: fixed; z-index: 10002;
            background: #f5f3ff; color: #6d28d9;
            border: 2px solid #ddd6fe; border-radius: 20px;
            padding: 25px; box-shadow: 0 10px 50px rgba(196, 181, 253, 0.5);
            font-family: 'Ubuntu', sans-serif;
            transition: transform 0.3s ease-in-out;
        }
        .cai-tracker-panel:hover, .cai-results-popup:hover {
             transform: translateY(-5px);
        }

        .cai-tracker-panel {
            bottom: 20px; right: 20px;
            width: 380px; text-align: center;
        }
        .cai-tracker-panel h2 {
            font-weight: 700; color: #8b5cf6;
            margin: 0 0 10px 0; font-size: 1.5rem;
        }
        .cai-tracker-panel p { font-size: 0.9rem; line-height: 1.4; color: #7c3aed; margin: 0 0 15px 0; }

        .cai-manual-button {
            background-image: linear-gradient(135deg, #e4d4f7, #d1c2f0);
            color: #5b21b6; border: none; padding: 12px 18px; width: 100%;
            border-radius: 12px; cursor: pointer; font-size: 1rem;
            font-weight: 700; transition: all 0.3s ease; margin-top: 10px;
            box-shadow: 0 4px 20px rgba(0,0,0,0.1);
        }
        .cai-manual-button:hover { transform: scale(1.03); background-image: linear-gradient(135deg, #d1c2f0, #c4b5fd); }
        .cai-manual-button.ready { background-image: linear-gradient(135deg, #34d399, #22c55e); color: white; }
        .cai-manual-button.capturing {
            background-image: linear-gradient(135deg, #fbbf24, #f59e0b);
            color: white;
            animation: pulse 2s infinite;
        }
        .cai-manual-button:disabled {
            background-image: none; background-color: #e9d5ff;
            cursor: not-allowed; opacity: 0.7; color: #9ca3af;
        }

        @keyframes pulse {
            0% { opacity: 1; }
            50% { opacity: 0.7; }
            100% { opacity: 1; }
        }

        .cai-status-text {
            margin-top: 15px; font-size: 0.9rem; font-weight: 700;
        }
        .cai-status-ok { color: #22c55e; }
        .cai-status-not-ok { color: #ef4444; }
        .cai-status-capturing { color: #f59e0b; }

        .cai-tracker-close-btn {
            position: absolute; top: 10px; right: 15px; background: none;
            border: none; color: #c4b5fd; font-size: 1.8rem; cursor: pointer;
            transition: transform 0.2s;
        }
        .cai-tracker-close-btn:hover { transform: scale(1.2); }

        .cai-tracker-toggle-btn {
            position: fixed; bottom: 20px; right: 20px; z-index: 10001;
            background: linear-gradient(135deg, #e4d4f7, #d1c2f0, #c4b5fd);
            border: none; border-radius: 50%; width: 60px; height: 60px;
            cursor: pointer; font-size: 2rem; color: #5b21b6;
            box-shadow: 0 8px 25px rgba(139, 92, 246, 0.4);
            transition: all 0.3s ease;
        }
        .cai-tracker-toggle-btn:hover {
            transform: scale(1.1);
            box-shadow: 0 12px 35px rgba(139, 92, 246, 0.6);
            background: linear-gradient(135deg, #d1c2f0, #c4b5fd, #a78bfa);
        }

        .cai-results-popup {
            top: 10%; right: 20px; max-height: 80vh; overflow-y: auto;
            width: 320px;
            background: linear-gradient(135deg, #f5f3ff, #ede9fe);
            background-size: 200% 200%;
            animation: pan-background 10s ease infinite;
        }
        @keyframes pan-background { 0% { background-position: 0% 50%; } 50% { background-position: 100% 50%; } 100% { background-position: 0% 50%; } }

        .cai-results-row { display: flex; align-items: center; margin-bottom: 8px; padding: 8px; border-radius: 10px; transition: background-color 0.2s; }
        .cai-results-row:hover { background-color: rgba(255,255,255,0.5); }
        .cai-results-row img { width: 40px; height: 40px; border-radius: 50%; margin-right: 12px; border: 2px solid #ddd6fe; }
        .cai-results-row span { color: #5b21b6; font-weight: 700; }

        .cai-toast-notification {
            position: fixed; top: 20px; left: 50%; transform: translateX(-50%);
            padding: 12px 25px; border-radius: 50px; font-family: 'Ubuntu', sans-serif;
            font-size: 1rem; font-weight: 700; color: white; z-index: 20001;
            box-shadow: 0 5px 15px rgba(0,0,0,0.2); opacity: 0;
            transition: opacity 0.4s ease, top 0.4s ease;
        }
        .cai-toast-notification.success { background-image: linear-gradient(to right, #34d399, #22c55e); }
        .cai-toast-notification.error { background-image: linear-gradient(to right, #f87171, #ef4444); }
        .cai-toast-notification.info { background-image: linear-gradient(to right, #60a5fa, #3b82f6); }
        .cai-toast-notification.show { top: 40px; opacity: 1; }

        .cai-progress-bar {
            width: 100%; height: 8px; background: #e9d5ff; border-radius: 4px;
            margin-top: 10px; overflow: hidden;
        }
        .cai-progress-fill {
            height: 100%; background: linear-gradient(90deg, #c4b5fd, #a78bfa);
            transition: width 0.3s ease; border-radius: 4px;
        }
    `;
    document.head.appendChild(document.createElement('style')).innerHTML = styleSheet;

    const APP_PREFIX = 'cai_tracker_v15_';
    let captureMode = null;
    let panelVisible = false;
    let isCapturing = false;
    let captureProgress = 0;
    let preparedMode = null;
    let modalObserver = null;

    // --- Toast Notification Function ---
    function showToast(message, type = 'success') {
        const toast = document.createElement('div');
        toast.className = `cai-toast-notification ${type}`;
        toast.textContent = message;
        document.body.appendChild(toast);
        setTimeout(() => toast.classList.add('show'), 10);
        setTimeout(() => {
            toast.classList.remove('show');
            setTimeout(() => toast.remove(), 500);
        }, 3000);
    }

    // --- ENHANCED SCROLL & CAPTURE LOGIC ---
    function prepareForCapture(type) {
        preparedMode = type;
        const btn = document.getElementById(`prepare-${type}-btn`);
        const instructionEl = document.getElementById('instruction-text');
        const instructionContent = document.getElementById('instruction-content');

        if (btn) {
            btn.classList.add('ready');
            btn.textContent = `✅ Ready! Now open your ${type} list`;
        }

        if (instructionEl && instructionContent) {
            instructionContent.textContent = `Now click on "${type}" in someone's profile to open the list. Capture will start automatically!`;
            instructionEl.style.display = 'block';
        }

        showToast(`Ready to capture ${type}! Now open the ${type} list.`, 'success');

        // Start watching for modal dialogs
        startModalWatcher();

        // Auto-reset after 60 seconds to prevent confusion
        setTimeout(() => {
            if (preparedMode === type) {
                resetPreparedMode();
                showToast(`Preparation timeout. Click "Prepare" again if needed.`, 'info');
            }
        }, 60000);
    }

    function resetPreparedMode() {
        const oldMode = preparedMode;
        preparedMode = null;

        if (oldMode) {
            const btn = document.getElementById(`prepare-${oldMode}-btn`);
            if (btn) {
                btn.classList.remove('ready');
                btn.textContent = `${oldMode === 'followers' ? '1' : '2'}. Prepare for ${oldMode.charAt(0).toUpperCase() + oldMode.slice(1)}`;
            }
        }

        const instructionEl = document.getElementById('instruction-text');
        if (instructionEl) {
            instructionEl.style.display = 'none';
        }

        stopModalWatcher();
    }

    // --- MODAL WATCHER FUNCTIONS ---
    function startModalWatcher() {
        stopModalWatcher(); // Clean up any existing watcher

        modalObserver = new MutationObserver((mutations) => {
            mutations.forEach((mutation) => {
                mutation.addedNodes.forEach((node) => {
                    if (node.nodeType === 1) { // Element node
                        // Check if this is a modal dialog
                        const dialog = node.querySelector ? node.querySelector('div[role="dialog"]') : null;
                        const isDialog = node.getAttribute && node.getAttribute('role') === 'dialog';

                        if (dialog || isDialog) {
                            console.log('[CAI Tracker] Modal detected, starting capture in 2 seconds...');
                            setTimeout(() => {
                                if (preparedMode) {
                                    const currentMode = preparedMode;
                                    resetPreparedMode(); // Reset first to prevent multiple captures
                                    captureAllUsers(currentMode);
                                }
                            }, 2000); // Wait 2 seconds for modal to fully load
                        }
                    }
                });
            });
        });

        modalObserver.observe(document.body, {
            childList: true,
            subtree: true
        });

        console.log('[CAI Tracker] Modal watcher started');
    }

    function stopModalWatcher() {
        if (modalObserver) {
            modalObserver.disconnect();
            modalObserver = null;
            console.log('[CAI Tracker] Modal watcher stopped');
        }
    }
    async function captureAllUsers(listType) {
        if (isCapturing) {
            showToast("Already capturing, please wait...", 'info');
            return;
        }

        isCapturing = true;
        const users = new Map();
        let lastCount = 0;
        let noChangeCount = 0;
        let scrollAttempts = 0;
        const maxScrollAttempts = 100;
        const maxNoChangeAttempts = 5;

        const statusBtn = document.getElementById(`prepare-${listType}-btn`);
        const statusEl = document.getElementById(`${listType}-status`);

        if (statusBtn) {
            statusBtn.classList.add('capturing');
            statusBtn.textContent = `🔄 Capturing ${listType}... 0 found`;
        }

        if (statusEl) {
            statusEl.className = 'cai-status-capturing';
            statusEl.textContent = `${listType}: Capturing... 0 found`;
        }

        try {
            // Find the dialog/modal
            let dialog = document.querySelector('div[role="dialog"]');
            if (!dialog) {
                dialog = document.querySelector('div[data-testid="modal"]');
            }
            if (!dialog) {
                dialog = document.querySelector('.modal, [class*="modal"], [class*="dialog"], [class*="overlay"]');
            }

            if (!dialog) {
                throw new Error("No dialog found. Make sure the followers/following list is open.");
            }

            // Find scrollable container
            let scrollableContainer = dialog.querySelector('[class*="scroll"], [class*="overflow"], [style*="overflow"]');
            if (!scrollableContainer) {
                // Look for container with many child elements (likely the list)
                const containers = dialog.querySelectorAll('div');
                for (const container of containers) {
                    if (container.children.length > 10) {
                        scrollableContainer = container;
                        break;
                    }
                }
            }
            if (!scrollableContainer) {
                scrollableContainer = dialog;
            }

            console.log('[CAI Tracker] Starting enhanced capture for:', listType);
            console.log('[CAI Tracker] Dialog:', dialog);
            console.log('[CAI Tracker] Scrollable container:', scrollableContainer);

            // Capture loop with auto-scrolling
            while (scrollAttempts < maxScrollAttempts && noChangeCount < maxNoChangeAttempts) {
                // Capture current users
                const currentUsers = captureUsersFromDOM(scrollableContainer);

                // Add new users to our collection
                currentUsers.forEach(user => {
                    if (!users.has(user.username)) {
                        users.set(user.username, user);
                    }
                });

                const currentCount = users.size;
                console.log(`[CAI Tracker] Attempt ${scrollAttempts + 1}: Found ${currentCount} users`);

                // Update UI
                if (statusBtn) {
                    statusBtn.textContent = `🔄 Capturing ${listType}... ${currentCount} found`;
                }
                if (statusEl) {
                    statusEl.textContent = `${listType}: Capturing... ${currentCount} found`;
                }

                // Check if we found new users
                if (currentCount === lastCount) {
                    noChangeCount++;
                } else {
                    noChangeCount = 0;
                    lastCount = currentCount;
                }

                // Scroll down to load more users
                scrollableContainer.scrollTop = scrollableContainer.scrollHeight;

                // Also try scrolling the dialog itself
                dialog.scrollTop = dialog.scrollHeight;

                // Wait for new content to load
                await new Promise(resolve => setTimeout(resolve, 1500));

                scrollAttempts++;

                // Break if we haven't found new users in several attempts
                if (noChangeCount >= maxNoChangeAttempts) {
                    console.log('[CAI Tracker] No new users found in', maxNoChangeAttempts, 'attempts. Stopping.');
                    break;
                }
            }

            const finalUsers = Array.from(users.values());
            console.log(`[CAI Tracker] Final capture complete: ${finalUsers.length} users`);

            if (finalUsers.length === 0) {
                throw new Error("No users found. The page structure might have changed.");
            }

            // Save to localStorage
            localStorage.setItem(APP_PREFIX + listType, JSON.stringify(finalUsers));

            showToast(`Success! Captured ${finalUsers.length} ${listType}.`, 'success');

            return finalUsers;

        } catch (error) {
            console.error('[CAI Tracker] Capture error:', error);
            showToast(`Error: ${error.message}`, 'error');
            return null;
        } finally {
            isCapturing = false;

            // Reset UI
            if (statusBtn) {
                statusBtn.classList.remove('capturing');
                resetPreparedMode(); // This will reset the button text
            }

            updateStatus();
        }
    }

    function captureUsersFromDOM(container) {
        const users = new Map();
        const processedElements = new Set();

        // Enhanced selectors for Character.AI
        const userContainerSelectors = [
            'a[href*="/profile/"]',
            'a[href*="/user/"]',
            'a[href*="@"]',
            'div[class*="user"]',
            'div[class*="profile"]',
            'div[class*="member"]',
            'div[class*="follow"]',
            'div[class*="avatar"]',
            '[data-testid*="user"]',
            '[data-testid*="profile"]',
            'div[role="button"]',
            'button',
            // More generic selectors
            'div > div > div', // Common nested structure
            'li',
            'article'
        ];

        userContainerSelectors.forEach(selector => {
            const elements = container.querySelectorAll(selector);

            elements.forEach(element => {
                if (processedElements.has(element)) return;
                processedElements.add(element);

                const userData = extractUserData(element);
                if (userData && userData.username && !users.has(userData.username)) {
                    users.set(userData.username, userData);
                }
            });
        });

        // If still no users, try a more aggressive approach
        if (users.size === 0) {
            const allElements = container.querySelectorAll('*');
            allElements.forEach(element => {
                if (processedElements.has(element)) return;

                const userData = extractUserData(element);
                if (userData && userData.username && !users.has(userData.username)) {
                    users.set(userData.username, userData);
                    processedElements.add(element);
                }
            });
        }

        return Array.from(users.values());
    }

    function extractUserData(element) {
        let username = '';
        let avatar = '';

        // Try to extract username from various sources
        const textSelectors = [
            'div[class*="text-ellipsis"]',
            'span[class*="text-ellipsis"]',
            '.username',
            '[class*="username"]',
            '[class*="name"]',
            'h1', 'h2', 'h3', 'h4', 'h5', 'h6',
            'div[class*="title"]',
            'span[class*="title"]',
            'p',
            'span',
            'div'
        ];

        // Try each selector
        for (const selector of textSelectors) {
            const textEl = element.querySelector(selector);
            if (textEl && textEl.textContent && textEl.textContent.trim()) {
                const text = textEl.textContent.trim();
                if (isValidUsername(text)) {
                    username = text;
                    break;
                }
            }
        }

        // If no username from child elements, try the element itself
        if (!username && element.textContent) {
            const text = element.textContent.trim();
            if (isValidUsername(text)) {
                username = text;
            }
        }

        // Extract avatar
        const img = element.querySelector('img');
        if (img && img.src && !img.src.includes('data:image')) {
            avatar = img.src;
        }

        // Clean up username
        if (username) {
            username = cleanUsername(username);
        }

        // Validate final username
        if (username && isValidUsername(username)) {
            return {
                username,
                avatar: avatar || 'https://placehold.co/40x40/f5f3ff/6d28d9?text=?'
            };
        }

        return null;
    }

    function isValidUsername(text) {
        if (!text || typeof text !== 'string') return false;

        // Clean the text first
        const cleaned = text.trim();

        // Check length
        if (cleaned.length === 0 || cleaned.length > 50) return false;

        // Exclude common UI text
        const excludePatterns = [
            'follow', 'following', 'followers', 'message', 'block', 'report',
            'back', 'close', 'cancel', 'ok', 'yes', 'no', 'save', 'edit',
            'delete', 'remove', 'add', 'create', 'new', 'search', 'filter',
            'sort', 'view', 'show', 'hide', 'more', 'less', 'next', 'previous',
            'loading', 'error', 'success', 'warning', 'info', 'help', 'about',
            'settings', 'profile', 'account', 'logout', 'login', 'sign',
            'register', 'submit', 'send', 'receive', 'inbox', 'notifications'
        ];

        const lowerText = cleaned.toLowerCase();
        if (excludePatterns.some(pattern => lowerText.includes(pattern))) {
            return false;
        }

        // Exclude pure numbers or numbers with common suffixes
        if (/^\d+$/.test(cleaned) || /^\d+\s*(followers?|following|posts?|likes?)$/i.test(cleaned)) {
            return false;
        }

        // Exclude text with line breaks
        if (cleaned.includes('\n')) return false;

        // Must have some alphanumeric characters
        if (!/[a-zA-Z0-9]/.test(cleaned)) return false;

        return true;
    }

    function cleanUsername(username) {
        if (!username) return '';

        // Take only the first line
        let cleaned = username.split('\n')[0].trim();

        // Remove leading numbers (like "1. username")
        cleaned = cleaned.replace(/^\d+\s*[.)\-]\s*/, '');

        // Remove trailing numbers in parentheses (like "username (123)")
        cleaned = cleaned.replace(/\s*\(\d+\)\s*$/, '');

        // Normalize whitespace
        cleaned = cleaned.replace(/\s+/g, ' ').trim();

        return cleaned;
    }

    // --- UI FUNCTIONS ---
    function createToggleButton() {
        const existingBtn = document.getElementById('cai-toggle-btn');
        if (existingBtn) return;

        const toggleBtn = document.createElement('button');
        toggleBtn.id = 'cai-toggle-btn';
        toggleBtn.className = 'cai-tracker-toggle-btn';
        toggleBtn.innerHTML = '🔍';
        toggleBtn.title = 'Toggle Follower Tracker';

        toggleBtn.addEventListener('click', function() {
            if (panelVisible) {
                hidePanel();
            } else {
                showPanel();
            }
        });

        document.body.appendChild(toggleBtn);
    }

    function showPanel() {
        const existingPanel = document.getElementById('cai-main-panel');
        if (existingPanel) {
            existingPanel.remove();
        }

        const panel = document.createElement('div');
        panel.id = 'cai-main-panel';
        panel.className = 'cai-tracker-panel';
        panel.innerHTML = `
            <button class="cai-tracker-close-btn">&times;</button>
            <h2>Follower Balance Tool</h2>
            <p><b>How to:</b> Click "Prepare" first, then open the followers/following list. The capture will start automatically when the list opens!

            (Scroll to the bottom of the selected list, it might not capture banned/deleted users)</p>
            <button id="prepare-followers-btn" class="cai-manual-button">1. Prepare for Followers</button>
            <button id="prepare-following-btn" class="cai-manual-button">2. Prepare for Following</button>
            <div id="instruction-text" style="margin-top: 10px; font-size: 0.8rem; color: #7c3aed; display: none;">
                📋 <span id="instruction-content"></span>
            </div>
            <div id="status-container" class="cai-status-text">
                <span id="followers-status" class="cai-status-not-ok">Followers: Not captured</span><br>
                <span id="following-status" class="cai-status-not-ok">Following: Not captured</span>
            </div>
            <button id="compare-btn" class="cai-manual-button" disabled>3. Compare & Show Results</button>
        `;
        document.body.appendChild(panel);

        // Add event listeners
        panel.querySelector('.cai-tracker-close-btn').addEventListener('click', hidePanel);

        document.getElementById('prepare-followers-btn').addEventListener('click', function() {
            if (isCapturing) return;
            prepareForCapture('followers');
        });

        document.getElementById('prepare-following-btn').addEventListener('click', function() {
            if (isCapturing) return;
            prepareForCapture('following');
        });

        document.getElementById('compare-btn').addEventListener('click', showResults);

        panelVisible = true;
        updateStatus();
    }

    function hidePanel() {
        const panel = document.getElementById('cai-main-panel');
        if (panel) {
            panel.remove();
        }
        panelVisible = false;
        captureMode = null;
        resetPreparedMode(); // Clean up any prepared state
    }

    function updateStatus() {
        const followers = JSON.parse(localStorage.getItem(APP_PREFIX + 'followers') || '[]');
        const following = JSON.parse(localStorage.getItem(APP_PREFIX + 'following') || '[]');
        const followersStatusEl = document.getElementById('followers-status');
        const followingStatusEl = document.getElementById('following-status');
        const compareBtn = document.getElementById('compare-btn');

        if (followersStatusEl && !followersStatusEl.className.includes('capturing')) {
            if (followers.length > 0) {
                followersStatusEl.textContent = `Followers: ${followers.length} captured`;
                followersStatusEl.className = 'cai-status-ok';
            } else {
                followersStatusEl.textContent = `Followers: Not captured`;
                followersStatusEl.className = 'cai-status-not-ok';
            }
        }

        if (followingStatusEl && !followingStatusEl.className.includes('capturing')) {
            if (following.length > 0) {
                followingStatusEl.textContent = `Following: ${following.length} captured`;
                followingStatusEl.className = 'cai-status-ok';
            } else {
                followingStatusEl.textContent = `Following: Not captured`;
                followingStatusEl.className = 'cai-status-not-ok';
            }
        }

        if (compareBtn) {
            compareBtn.disabled = !(followers.length > 0 && following.length > 0) || isCapturing;
        }
    }

    function showResults() {
        const followingList = JSON.parse(localStorage.getItem(APP_PREFIX + 'following') || "[]");
        const followersList = JSON.parse(localStorage.getItem(APP_PREFIX + 'followers') || "[]");

        if (followingList.length === 0 || followersList.length === 0) {
            showToast("Please capture both lists before comparing.", 'error');
            return;
        }

        const followerUsernames = new Set(followersList.map(u => u.username.toLowerCase()));
        const notFollowingBack = followingList.filter(u => !followerUsernames.has(u.username.toLowerCase()));

        const existing = document.querySelector('.cai-results-popup');
        if (existing) existing.remove();

        const wrapper = document.createElement("div");
        wrapper.className = 'cai-results-popup';

        let userListHTML = notFollowingBack.map(user => `
            <div class="cai-results-row">
                <img src="${user.avatar}" alt="${user.username}'s avatar" onerror="this.src='https://placehold.co/40x40/f5f3ff/6d28d9?text=?'">
                <span>${user.username}</span>
            </div>
        `).join('');

        if (notFollowingBack.length === 0) {
            userListHTML = "<p style='color: #581c87; text-align: center; padding: 20px;'>Everyone you follow follows you back!</p>";
        }

        wrapper.innerHTML = `
            <button class="cai-tracker-close-btn">&times;</button>
            <h3>Doesn't Follow Back (${notFollowingBack.length})</h3>
            <p style="font-size: 0.8rem; color: #7c3aed; margin-bottom: 15px;">
                📊 Followers: ${followersList.length} | Following: ${followingList.length}
            </p>
            ${userListHTML}
        `;

        document.body.appendChild(wrapper);
        wrapper.querySelector('.cai-tracker-close-btn').addEventListener('click', () => wrapper.remove());
    }

    function init() {
        setTimeout(() => {
            createToggleButton();
        }, 1000);
    }

    // Initialize
    if (document.readyState === 'loading') {
        document.addEventListener('DOMContentLoaded', init);
    } else {
        init();
    }

    // Backup observer
    const observer = new MutationObserver(() => {
        if (document.body && !document.getElementById('cai-toggle-btn')) {
            createToggleButton();
        }
    });

    if (document.body) {
        observer.observe(document.body, { childList: true, subtree: true });
    }

})();
长期地址
遇到问题?请前往 GitHub 提 Issues。