GitHub Follower Tracker

Track GitHub followers&following

// ==UserScript==
// @name         GitHub Follower Tracker
// @namespace    http://tampermonkey.net/
// @version      1.1
// @description  Track GitHub followers&following
// @author       maanimis
// @match        https://github.com/*?tab=following
// @grant        GM_getValue
// @grant        GM_setValue
// @grant        GM_listValues
// @run-at       document-end
// @license MIT
// @icon https://github.githubassets.com/pinned-octocat.svg
// ==/UserScript==

(function () {
  "use strict";

  const isDebugMode = false;

  const FOLLOWERS_PER_PAGE = 100;
  const API_BASE_URL = "https://api.github.com/users";

  let currentFollowers = [];
  let currentFollowing = [];
  let hasMorePages = false;
  let notFollowingBackList = [];
  let followsYouList = [];
  let notFollowingYouList = [];
  let isDataLoaded = false;

  function log(...args) {
    if (isDebugMode) {
      console.log(...args);
    }
  }

  function init() {
    const username = extractUsernameFromPage();
    if (!username) {
      log("[GitHub Tracker] Not on a user profile page");
      return;
    }

    log(`[GitHub Tracker] Tracking user: ${username}`);
    addLoadingLabels();
    processUserData(username);
  }

  function extractUsernameFromPage() {
    const href = document
      .querySelector('a[class="AppHeader-context-item"]')
      .getAttribute("href");
    return href.slice(1);
  }

  async function processUserData(username) {
    try {
      const followersUrl = `${API_BASE_URL}/${username}/followers?per_page=${FOLLOWERS_PER_PAGE}&page=`;
      const followingUrl = `${API_BASE_URL}/${username}/following?per_page=${FOLLOWERS_PER_PAGE}&page=`;

      log("[GitHub Tracker] Fetching followers...");
      currentFollowers = await fetchAllPages(followersUrl);
      log(`[GitHub Tracker] Total followers: ${currentFollowers.length}`);

      log("[GitHub Tracker] Fetching following...");
      currentFollowing = await fetchAllPages(followingUrl);
      log(`[GitHub Tracker] Total following: ${currentFollowing.length}`);

      notFollowingBackList = findNotFollowingBack();
      followsYouList = findFollowsYou();
      notFollowingYouList = findNotFollowingYou();

      logNotFollowingBack();

      const previousData = loadStoredData(username);

      if (previousData) {
        const changes = calculateChanges(previousData);
        logChanges(changes, previousData.timestamp);
      } else {
        log(`[GitHub Tracker] First time tracking ${username}`);
      }
      saveUserData(username);

      isDataLoaded = true;
      addFollowLabels();
    } catch (error) {
      log(`[GitHub Tracker] Error: ${error.message}`);
    }
  }

  async function fetchAllPages(baseUrl) {
    let page = 0;
    let allResults = [];

    do {
      hasMorePages = false;
      page++;

      const pageResults = await fetchPage(baseUrl, page);
      log(`[GitHub Tracker] Fetched page ${page}: ${pageResults.length} items`);

      allResults.push(...pageResults);

      if (pageResults.length === FOLLOWERS_PER_PAGE) {
        hasMorePages = true;
      }
    } while (hasMorePages);

    return allResults;
  }

  async function fetchPage(url, page = 1) {
    const response = await fetch(url + page);

    if (response.status === 404) {
      throw new Error("Username doesn't exist");
    }

    if (response.status === 403) {
      throw new Error("API rate limit exceeded");
    }

    return await response.json();
  }

  function saveUserData(username) {
    const dataToSave = {
      followers: currentFollowers,
      following: currentFollowing,
      timestamp: new Date().toISOString(),
    };

    GM_setValue(username, JSON.stringify(dataToSave));
    log(`[GitHub Tracker] Saved data for ${username}`);
  }

  function loadStoredData(username) {
    const storedData = GM_getValue(username);
    return storedData ? JSON.parse(storedData) : null;
  }

  function calculateChanges(previousData) {
    const removedFollowing = findRemovedUsers(
      previousData.following,
      currentFollowing
    );
    const addedFollowing = findAddedUsers(
      previousData.following,
      currentFollowing
    );
    const removedFollowers = findRemovedUsers(
      previousData.followers,
      currentFollowers
    );
    const addedFollowers = findAddedUsers(
      previousData.followers,
      currentFollowers
    );

    return {
      removedFollowing,
      addedFollowing,
      removedFollowers,
      addedFollowers,
    };
  }

  function findRemovedUsers(previousList, currentList) {
    return previousList.filter(
      (prevUser) => !currentList.some((currUser) => currUser.id === prevUser.id)
    );
  }

  function findAddedUsers(previousList, currentList) {
    return currentList.filter(
      (currUser) =>
        !previousList.some((prevUser) => prevUser.id === currUser.id)
    );
  }

  function findNotFollowingBack() {
    return currentFollowing.filter(
      (followingUser) =>
        !currentFollowers.some((follower) => follower.id === followingUser.id)
    );
  }

  function findFollowsYou() {
    return currentFollowers.filter((follower) =>
      currentFollowing.some((followingUser) => followingUser.id === follower.id)
    );
  }

  function findNotFollowingYou() {
    return currentFollowers.filter(
      (follower) =>
        !currentFollowing.some(
          (followingUser) => followingUser.id === follower.id
        )
    );
  }

  function logNotFollowingBack() {
    log(
      `\n[GitHub Tracker] 🔍 NOT FOLLOWING BACK (${notFollowingBackList.length} users):`
    );
    log("━".repeat(50));

    if (notFollowingBackList.length === 0) {
      log("   Everyone you follow is following you back! 🎉");
    } else {
      notFollowingBackList.forEach((user) => {
        log(`   • ${user.login} (${user.html_url})`);
      });
    }

    log("━".repeat(50));
  }

  function logChanges(changes, lastCheckDate) {
    const date = new Date(lastCheckDate).toLocaleString();
    log(`\n[GitHub Tracker] Changes since ${date}:`);
    log("━".repeat(50));

    log(
      `\n📊 FOLLOWING CHANGES (${
        changes.removedFollowing.length + changes.addedFollowing.length
      } total):`
    );
    logUserList("➖ Unfollowed", changes.removedFollowing);
    logUserList("➕ New Following", changes.addedFollowing);

    log(
      `\n📊 FOLLOWERS CHANGES (${
        changes.removedFollowers.length + changes.addedFollowers.length
      } total):`
    );
    logUserList("➖ Lost Followers", changes.removedFollowers);
    logUserList("➕ New Followers", changes.addedFollowers);

    log("\n" + "━".repeat(50));
  }

  function logUserList(label, userList) {
    if (userList.length === 0) {
      log(`   ${label}: None`);
      return;
    }

    log(`   ${label}: ${userList.length}`);
    userList.forEach((user) => {
      log(`      • ${user.login} (${user.html_url})`);
    });
  }

  function addLoadingLabels() {
    const usernameElements = document.querySelectorAll(
      'span[class="Link--secondary pl-1"]'
    );

    usernameElements.forEach((element) => {
      const existingLabel = element.nextElementSibling;
      if (
        existingLabel &&
        existingLabel.classList.contains("follow-status-label")
      ) {
        return;
      }

      const labelSpan = document.createElement("span");
      labelSpan.className = "follow-status-label loading-label";
      labelSpan.textContent = "loading...";
      labelSpan.style.cssText = `
        margin-left: 8px;
        padding: 3px 10px;
        background: linear-gradient(135deg, #8b949e 0%, #6e7781 100%);
        color: #ffffff;
        border-radius: 14px;
        font-size: 11px;
        font-weight: 600;
        letter-spacing: 0.3px;
        text-transform: uppercase;
        box-shadow: 0 2px 4px rgba(0, 0, 0, 0.15);
        display: inline-block;
        vertical-align: middle;
        opacity: 0.7;
        animation: pulse 1.5s ease-in-out infinite;
      `;

      element.parentNode.insertBefore(labelSpan, element.nextSibling);
    });

    const style = document.createElement("style");
    style.textContent = `
      @keyframes pulse {
        0%, 100% { opacity: 0.7; }
        50% { opacity: 0.4; }
      }
    `;
    document.head.appendChild(style);
  }

  function addFollowLabels() {
    const usernameElements = document.querySelectorAll(
      'span[class="Link--secondary pl-1"]'
    );

    usernameElements.forEach((element) => {
      const username = element.textContent.trim().toLowerCase();

      const existingLabel = element.nextElementSibling;
      if (
        existingLabel &&
        existingLabel.classList.contains("follow-status-label")
      ) {
        if (!isDataLoaded) {
          return;
        }
        if (existingLabel.classList.contains("loading-label")) {
          existingLabel.remove();
        } else {
          return;
        }
      }

      const isNotFollowingBack = notFollowingBackList.some(
        (user) => user.login.toLowerCase() === username
      );

      const isFollowsYou = followsYouList.some(
        (user) => user.login.toLowerCase() === username
      );

      const isNotFollowingYou = notFollowingYouList.some(
        (user) => user.login.toLowerCase() === username
      );

      let labelText = "";
      let backgroundColor = "";
      let textColor = "#ffffff";

      if (isNotFollowingBack) {
        labelText = "not following";
        backgroundColor = "#d73a49";
      } else if (isFollowsYou) {
        labelText = "follows you";
        backgroundColor = "#28a745";
      } else if (isNotFollowingYou) {
        labelText = "not following you";
        backgroundColor = "#6a737d";
      }

      if (labelText) {
        const labelSpan = document.createElement("span");
        labelSpan.className = "follow-status-label";
        labelSpan.textContent = labelText;
        labelSpan.style.cssText = `
          margin-left: 8px;
          padding: 3px 10px;
          background: linear-gradient(135deg, ${backgroundColor} 0%, ${adjustBrightness(
          backgroundColor,
          -15
        )} 100%);
          color: ${textColor};
          border-radius: 14px;
          font-size: 11px;
          font-weight: 600;
          letter-spacing: 0.3px;
          text-transform: uppercase;
          box-shadow: 0 2px 4px rgba(0, 0, 0, 0.15);
          display: inline-block;
          vertical-align: middle;
          transition: all 0.2s ease;
        `;

        labelSpan.addEventListener("mouseenter", function () {
          this.style.transform = "translateY(-1px)";
          this.style.boxShadow = "0 3px 6px rgba(0, 0, 0, 0.2)";
        });

        labelSpan.addEventListener("mouseleave", function () {
          this.style.transform = "translateY(0)";
          this.style.boxShadow = "0 2px 4px rgba(0, 0, 0, 0.15)";
        });

        element.parentNode.insertBefore(labelSpan, element.nextSibling);
      }
    });
  }

  function adjustBrightness(color, percent) {
    const num = parseInt(color.replace("#", ""), 16);
    const amt = Math.round(2.55 * percent);
    const R = (num >> 16) + amt;
    const G = ((num >> 8) & 0x00ff) + amt;
    const B = (num & 0x0000ff) + amt;
    return (
      "#" +
      (
        0x1000000 +
        (R < 255 ? (R < 1 ? 0 : R) : 255) * 0x10000 +
        (G < 255 ? (G < 1 ? 0 : G) : 255) * 0x100 +
        (B < 255 ? (B < 1 ? 0 : B) : 255)
      )
        .toString(16)
        .slice(1)
    );
  }

  const observer = new MutationObserver(() => {
    addFollowLabels();
  });

  if (document.readyState === "loading") {
    document.addEventListener("DOMContentLoaded", () => {
      init();
      observer.observe(document.body, {
        childList: true,
        subtree: true,
      });
    });
  } else {
    init();
    observer.observe(document.body, {
      childList: true,
      subtree: true,
    });
  }
})();