Twitter Followers Mutual Tracker

Track mutual and non-mutual Twitter followers and download as JSON

  1. // ==UserScript==
  2. // @name Twitter Followers Mutual Tracker
  3. // @description Track mutual and non-mutual Twitter followers and download as JSON
  4. // @namespace https://github.com/kaubu
  5. // @version 1.1.1
  6. // @author kaubu (https://github.com/kaubu)
  7. // @match https://twitter.com/*/followers
  8. // @match https://twitter.com/*/following
  9. // @match https://x.com/*/followers
  10. // @match https://x.com/*/following
  11. // @grant none
  12. // @license 0BSD
  13. // ==/UserScript==
  14.  
  15. (function() {
  16. 'use strict';
  17.  
  18. // Arrays to store mutuals and non-mutuals
  19. let mutuals = [];
  20. let nonMutuals = [];
  21.  
  22. // Flag to track if the script is active
  23. let isTrackerActive = true;
  24.  
  25. // Auto-scroll variables
  26. let isAutoScrolling = false;
  27. let autoScrollInterval;
  28. let lastUserCount = 0;
  29. let noNewUsersCounter = 0;
  30.  
  31. // Helper: Extract display name
  32. function getDisplayName(cell) {
  33. // Try to find the display name in the most robust way
  34. // Twitter/X often uses the first span in the first link for display name
  35. const link = cell.querySelector('a[role="link"]:not([tabindex="-1"])');
  36. if (link) {
  37. const span = link.querySelector('span');
  38. if (span) return span.textContent.trim();
  39. }
  40. // Fallback to previous selector
  41. const fallback = cell.querySelector('.css-1jxf684.r-bcqeeo.r-1ttztb7.r-qvutc0.r-poiln3:not([style*="display: none"])');
  42. return fallback ? fallback.textContent.trim() : '';
  43. }
  44.  
  45. // Helper: Extract username
  46. function getUsername(cell) {
  47. // Try to find the username by looking for a span starting with '@'
  48. const spans = cell.querySelectorAll('span');
  49. for (const span of spans) {
  50. if (span.textContent.trim().startsWith('@')) {
  51. return span.textContent.trim();
  52. }
  53. }
  54. // Fallback to previous selector
  55. const fallback = cell.querySelector('[style*="color: rgb(113, 118, 123)"] .css-1jxf684.r-bcqeeo.r-1ttztb7.r-qvutc0.r-poiln3');
  56. return fallback ? fallback.textContent.trim() : '';
  57. }
  58.  
  59. // Helper: Extract profile URL
  60. function getProfileUrl(cell, username) {
  61. // Try to find the first profile link
  62. const link = cell.querySelector('a[role="link"]:not([tabindex="-1"])');
  63. if (link && link.getAttribute('href') && link.getAttribute('href').startsWith('/')) {
  64. return `https://x.com${link.getAttribute('href')}`;
  65. }
  66. // Fallback to constructing from username
  67. return username ? `https://x.com/${username.replace('@', '')}` : '';
  68. }
  69.  
  70. // Helper: Check if element is within "You might like" section
  71. function isInYouMightLikeSection(element) {
  72. // Check if this element or any parent has the "You might like" heading
  73. let current = element;
  74. const maxDepth = 10; // Prevent infinite loop
  75. let depth = 0;
  76.  
  77. while (current && depth < maxDepth) {
  78. // Check if it's within a section with "You might like" heading
  79. const headingNearby = current.querySelector('h2 span');
  80. if (headingNearby && headingNearby.textContent.includes('You might like')) {
  81. return true;
  82. }
  83.  
  84. // Check if this is inside aside[aria-label="Who to follow"]
  85. const aside = current.closest('aside[aria-label="Who to follow"]');
  86. if (aside) {
  87. return true;
  88. }
  89.  
  90. current = current.parentElement;
  91. depth++;
  92. }
  93.  
  94. return false;
  95. }
  96.  
  97. // Function to start auto-scrolling
  98. function startAutoScroll() {
  99. if (isAutoScrolling) return;
  100.  
  101. isAutoScrolling = true;
  102. noNewUsersCounter = 0;
  103. lastUserCount = mutuals.length + nonMutuals.length;
  104. document.getElementById('auto-scroll-btn').textContent = 'Stop Auto-Scroll';
  105.  
  106. autoScrollInterval = setInterval(() => {
  107. // Scroll down by a small amount
  108. window.scrollBy(0, 300);
  109.  
  110. // Get current total users
  111. const currentUserCount = mutuals.length + nonMutuals.length;
  112.  
  113. // Check if we've found new users
  114. if (currentUserCount > lastUserCount) {
  115. // Reset the counter if we found new users
  116. noNewUsersCounter = 0;
  117. lastUserCount = currentUserCount;
  118. } else {
  119. // Increment counter if no new users found
  120. noNewUsersCounter++;
  121. }
  122.  
  123. // If we haven't found new users after several scrolls, consider it done
  124. if (noNewUsersCounter >= 10) {
  125. // Play beep sound
  126. playBeepSound();
  127.  
  128. // Stop auto-scrolling
  129. stopAutoScroll();
  130. }
  131. }, 500);
  132. }
  133.  
  134. // Function to stop auto-scrolling
  135. function stopAutoScroll() {
  136. if (!isAutoScrolling) return;
  137.  
  138. isAutoScrolling = false;
  139. clearInterval(autoScrollInterval);
  140. document.getElementById('auto-scroll-btn').textContent = 'Auto-Scroll to Bottom';
  141. }
  142.  
  143. // Function to play a beep sound
  144. function playBeepSound() {
  145. const audioContext = new (window.AudioContext || window.webkitAudioContext)();
  146. const oscillator = audioContext.createOscillator();
  147. const gainNode = audioContext.createGain();
  148.  
  149. oscillator.type = 'sine';
  150. oscillator.frequency.value = 220; // Lower A3 note for a deeper sound
  151. gainNode.gain.value = 0.1; // Low volume
  152.  
  153. oscillator.connect(gainNode);
  154. gainNode.connect(audioContext.destination);
  155.  
  156. oscillator.start();
  157. setTimeout(() => {
  158. oscillator.stop();
  159. }, 300);
  160. }
  161.  
  162. // Function to close the tracker and cleanup
  163. function closeTracker() {
  164. // Stop auto-scrolling if active
  165. if (isAutoScrolling) {
  166. stopAutoScroll();
  167. }
  168.  
  169. // Remove the status div
  170. const statusDiv = document.getElementById('mutual-tracker-status');
  171. if (statusDiv) {
  172. statusDiv.remove();
  173. }
  174.  
  175. // Set the tracker as inactive
  176. isTrackerActive = false;
  177.  
  178. // Remove event listeners
  179. window.removeEventListener('scroll', processUserCells);
  180. window.removeEventListener('resize', processUserCells);
  181. }
  182.  
  183. // Function to add the status div and buttons
  184. function addStatusDiv() {
  185. // Don't add if tracker is inactive
  186. if (!isTrackerActive) return;
  187.  
  188. // Remove any existing status div
  189. const existingDiv = document.getElementById('mutual-tracker-status');
  190. if (existingDiv) {
  191. existingDiv.remove();
  192. }
  193.  
  194. // Create the status div
  195. const statusDiv = document.createElement('div');
  196. statusDiv.id = 'mutual-tracker-status';
  197. statusDiv.style.position = 'fixed';
  198. statusDiv.style.bottom = '20px';
  199. statusDiv.style.right = '20px';
  200. statusDiv.style.backgroundColor = '#1DA1F2';
  201. statusDiv.style.color = 'white';
  202. statusDiv.style.padding = '15px';
  203. statusDiv.style.borderRadius = '8px';
  204. statusDiv.style.zIndex = '10000';
  205. statusDiv.style.fontSize = '14px';
  206. statusDiv.style.fontFamily = 'Arial, sans-serif';
  207. statusDiv.style.boxShadow = '0 2px 10px rgba(0,0,0,0.3)';
  208. statusDiv.style.width = '220px';
  209. statusDiv.style.maxWidth = '90vw';
  210. statusDiv.style.boxSizing = 'border-box';
  211.  
  212. // Create header with close button
  213. const headerDiv = document.createElement('div');
  214. headerDiv.style.display = 'flex';
  215. headerDiv.style.justifyContent = 'space-between';
  216. headerDiv.style.alignItems = 'center';
  217. headerDiv.style.marginBottom = '12px';
  218.  
  219. const headerTitle = document.createElement('div');
  220. headerTitle.textContent = 'Mutual Tracker';
  221. headerTitle.style.fontWeight = 'bold';
  222.  
  223. const closeButton = document.createElement('div');
  224. closeButton.textContent = '✕';
  225. closeButton.style.cursor = 'pointer';
  226. closeButton.style.fontSize = '16px';
  227. closeButton.style.lineHeight = '16px';
  228. closeButton.addEventListener('click', closeTracker);
  229.  
  230. headerDiv.appendChild(headerTitle);
  231. headerDiv.appendChild(closeButton);
  232. statusDiv.appendChild(headerDiv);
  233.  
  234. // Create the status text
  235. const statusText = document.createElement('div');
  236. const totalAccounts = mutuals.length + nonMutuals.length;
  237. statusText.innerHTML = `Mutuals: ${mutuals.length} | Non-Mutuals: ${nonMutuals.length}<br>Total Accounts: ${totalAccounts}`;
  238. statusText.style.marginBottom = '12px';
  239. statusDiv.appendChild(statusText);
  240.  
  241. // Create button container for consistent styling
  242. const buttonContainer = document.createElement('div');
  243. buttonContainer.style.display = 'flex';
  244. buttonContainer.style.flexDirection = 'column';
  245. buttonContainer.style.gap = '8px';
  246.  
  247. // Create the auto-scroll button
  248. const autoScrollButton = document.createElement('button');
  249. autoScrollButton.id = 'auto-scroll-btn';
  250. autoScrollButton.textContent = 'Auto-Scroll to Bottom';
  251. autoScrollButton.style.padding = '8px 10px';
  252. autoScrollButton.style.borderRadius = '4px';
  253. autoScrollButton.style.border = 'none';
  254. autoScrollButton.style.backgroundColor = 'white';
  255. autoScrollButton.style.color = '#1DA1F2';
  256. autoScrollButton.style.cursor = 'pointer';
  257. autoScrollButton.style.fontWeight = 'bold';
  258. autoScrollButton.style.width = '100%';
  259. autoScrollButton.style.transition = 'background-color 0.2s';
  260.  
  261. autoScrollButton.addEventListener('mouseover', function() {
  262. this.style.backgroundColor = '#f0f0f0';
  263. });
  264.  
  265. autoScrollButton.addEventListener('mouseout', function() {
  266. this.style.backgroundColor = 'white';
  267. });
  268.  
  269. // Add event listener to auto-scroll button
  270. autoScrollButton.addEventListener('click', function() {
  271. if (isAutoScrolling) {
  272. stopAutoScroll();
  273. } else {
  274. startAutoScroll();
  275. }
  276. });
  277.  
  278. buttonContainer.appendChild(autoScrollButton);
  279.  
  280. // Create the download button
  281. const downloadButton = document.createElement('button');
  282. downloadButton.textContent = 'Download Data';
  283. downloadButton.style.padding = '8px 10px';
  284. downloadButton.style.borderRadius = '4px';
  285. downloadButton.style.border = 'none';
  286. downloadButton.style.backgroundColor = 'white';
  287. downloadButton.style.color = '#1DA1F2';
  288. downloadButton.style.cursor = 'pointer';
  289. downloadButton.style.fontWeight = 'bold';
  290. downloadButton.style.width = '100%';
  291. downloadButton.style.transition = 'background-color 0.2s';
  292.  
  293. downloadButton.addEventListener('mouseover', function() {
  294. this.style.backgroundColor = '#f0f0f0';
  295. });
  296.  
  297. downloadButton.addEventListener('mouseout', function() {
  298. this.style.backgroundColor = 'white';
  299. });
  300.  
  301. // Add event listener to download button
  302. downloadButton.addEventListener('click', function() {
  303. downloadData();
  304. });
  305.  
  306. buttonContainer.appendChild(downloadButton);
  307. statusDiv.appendChild(buttonContainer);
  308. document.body.appendChild(statusDiv);
  309. }
  310.  
  311. // Function to download the data
  312. function downloadData() {
  313. const data = {
  314. mutuals: mutuals,
  315. nonMutuals: nonMutuals,
  316. totalMutuals: mutuals.length,
  317. totalNonMutuals: nonMutuals.length
  318. };
  319.  
  320. const dataStr = JSON.stringify(data, null, 2);
  321. const dataBlob = new Blob([dataStr], {type: 'application/json'});
  322. const url = URL.createObjectURL(dataBlob);
  323.  
  324. const a = document.createElement('a');
  325. a.href = url;
  326. a.download = 'x_twitter_mutual_data.json';
  327. a.click();
  328.  
  329. URL.revokeObjectURL(url);
  330. }
  331.  
  332. // Function to check if an element is visible
  333. function isElementInViewport(el) {
  334. const rect = el.getBoundingClientRect();
  335. return (
  336. rect.top >= 0 &&
  337. rect.left >= 0 &&
  338. rect.bottom <= (window.innerHeight || document.documentElement.clientHeight) &&
  339. rect.right <= (window.innerWidth || document.documentElement.clientWidth)
  340. );
  341. }
  342.  
  343. // Function to process user cells
  344. function processUserCells() {
  345. // Don't process if tracker is inactive
  346. if (!isTrackerActive) return;
  347.  
  348. const userCells = document.querySelectorAll('[data-testid="UserCell"]');
  349.  
  350. userCells.forEach(cell => {
  351. // Skip if already processed
  352. if (cell.dataset.processed === 'true') {
  353. return;
  354. }
  355.  
  356. // Only process visible cells
  357. if (!isElementInViewport(cell)) {
  358. return;
  359. }
  360.  
  361. // Skip if in "You might like" section
  362. if (isInYouMightLikeSection(cell)) {
  363. cell.dataset.processed = 'true';
  364. return;
  365. }
  366.  
  367. try {
  368. // Get display name
  369. const displayName = getDisplayName(cell);
  370.  
  371. // Get username
  372. const username = getUsername(cell);
  373.  
  374. // Get URL
  375. const url = getProfileUrl(cell, username);
  376.  
  377. // Check if mutual (has "Follows you" indicator)
  378. const followsYouIndicator = cell.querySelector('[data-testid="userFollowIndicator"]');
  379.  
  380. // Create user object
  381. const userObject = {
  382. displayName: displayName,
  383. username: username,
  384. url: url
  385. };
  386.  
  387. // Add to appropriate array
  388. if (username) {
  389. if (followsYouIndicator && !mutuals.some(m => m.username === username)) {
  390. mutuals.push(userObject);
  391. } else if (!followsYouIndicator && !nonMutuals.some(nm => nm.username === username)) {
  392. nonMutuals.push(userObject);
  393. }
  394. }
  395.  
  396. // Mark as processed
  397. cell.dataset.processed = 'true';
  398.  
  399. // Update status
  400. addStatusDiv();
  401. } catch (error) {
  402. console.error('Error processing user cell:', error);
  403. }
  404. });
  405. }
  406.  
  407. // Function to initialize the observer
  408. function initObserver() {
  409. const observer = new MutationObserver(function() {
  410. if (isTrackerActive) {
  411. processUserCells();
  412. }
  413. });
  414.  
  415. observer.observe(document.body, {
  416. childList: true,
  417. subtree: true
  418. });
  419.  
  420. // Initial processing
  421. processUserCells();
  422.  
  423. // Add status initially
  424. addStatusDiv();
  425. }
  426.  
  427. // Initialize when page is loaded
  428. window.addEventListener('load', function() {
  429. setTimeout(initObserver, 1500);
  430. });
  431.  
  432. // Also process on scroll and resize (for dynamic content)
  433. window.addEventListener('scroll', processUserCells);
  434. window.addEventListener('resize', processUserCells);
  435.  
  436. })();