// ==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">×</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">×</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 });
}
})();