Raw Source
aranciro / Twitch Follower Count

// ==UserScript==
// @name            Twitch Follower Count
// @namespace       https://github.com/aranciro/
// @version         1.2.1
// @license         GPL-3.0-or-later; https://www.gnu.org/licenses/gpl-3.0.txt
// @description     Configurable browser userscript that shows follower count when in a twitch channel page.
// @author          aranciro
// @homepage        https://github.com/aranciro/Twitch-Follower-Count
// @supportURL      https://github.com/aranciro/Twitch-Follower-Count/issues
// @updateURL       https://raw.githubusercontent.com/aranciro/Twitch-Follower-Count/master/twitch-follower-count.user.js
// @downloadURL     https://raw.githubusercontent.com/aranciro/Twitch-Follower-Count/master/twitch-follower-count.user.js
// @icon            https://github.com/aranciro/Twitch-Follower-Count/raw/master/res/twitch-follower-count-icon32.png
// @icon64          https://github.com/aranciro/Twitch-Follower-Count/raw/master/res/twitch-follower-count-icon64.png
// @require         https://openuserjs.org/src/libs/sizzle/GM_config.min.js
// @grant           GM_getValue
// @grant           GM_setValue
// @grant           GM_registerMenuCommand
// @grant           GM_addStyle
// @include         *://*.twitch.tv/*
// @run-at          document-idle
// ==/UserScript==

const pollingInterval = 5000;
const followerCountNodeName = "ChannelFollowerCount";
const channelPageTitleRegExp = /^([^\s-]+)\s-\s(?:T|t)witch$/;

let titleObserver;

const selectors = {
  channelNameAnchorNode: "div.channel-info-content a[href^='/']",
  channelNameNode: "div.channel-info-content a[href^='/'] > h1.tw-title",
  channelPartnerBadgeNode:
    "div.channel-info-content div.sc-AxjAm.gOCWUc > figure.tw-svg > svg",
  divNextToButtonsNode:
    "div.sc-AxjAm.StDqN.metadata-layout__secondary-button-spacing",
  divWithButtonsRowNode: "div.sc-AxjAm.fAqNuL",
};

const configLiterals = {
  smallFontSize: "Small",
  mediumFontSize: "Medium",
  bigFontSize: "Big",
  positionChannelName: "Next to channel name",
  positionFollowButton: "Left of the follow button",
};

const fontSizeMap = {
  [configLiterals.smallFontSize]: "5",
  [configLiterals.mediumFontSize]: "4",
  [configLiterals.bigFontSize]: "3",
};

GM_config.init({
  id: "Twitch_Follower_Count_config",
  title: "Twitch Follower Count - Configuration",
  fields: {
    fontSize: {
      label: "Font size",
      type: "select",
      options: [
        configLiterals.smallFontSize,
        configLiterals.mediumFontSize,
        configLiterals.bigFontSize,
      ],
      default: configLiterals.mediumFontSize,
      title: "Select the follower count font size",
    },
    position: {
      label: "Position",
      type: "select",
      options: [
        configLiterals.positionChannelName,
        configLiterals.positionFollowButton,
      ],
      default: configLiterals.positionChannelName,
      title: "Select where the follower count should appear",
    },
    localeString: {
      type: "checkbox",
      default: true,
      label: "Format the follower count (puts thousands separator etc.)",
      title:
        "Uncheck if you don't want the follower count to have separator for thousands",
    },
    enclosed: {
      type: "checkbox",
      default: false,
      label: "Parenthesize the follower count",
      title: "Parenthesize the follower count",
    },
  },
  events: {
    save: () => {
      updateConfig();
    },
  },
});

GM_addStyle(`
@keyframes blinker { 50% { opacity: 0; } } 
.updating-counter { animation: blinker 1s linear infinite; } 
`);

GM_registerMenuCommand("Configure Twitch Follower Count", () => {
  GM_config.open();
});

const currentChannel = {
  name: undefined,
  nameShown: undefined,
  followers: undefined,
};

const config = {
  fontSize: fontSizeMap[GM_config.get("fontSize")],
  insertNextToFollowButton:
    configLiterals.positionFollowButton === GM_config.get("position"),
  localeString: GM_config.get("localeString"),
  enclosed: GM_config.get("enclosed"),
};

const updateConfig = () => {
  config.fontSize = fontSizeMap[GM_config.get("fontSize")];
  config.insertNextToFollowButton =
    configLiterals.positionFollowButton === GM_config.get("position");
  config.localeString = GM_config.get("localeString");
  config.enclosed = GM_config.get("enclosed");
  removeExistingFollowerCountNodes();
  insertFollowerCountNode();
};

const run = () => {
  const channelNameNode = document.querySelector(selectors.channelNameNode);
  if (channelNameNode) {
    const followerCountNodes = document.getElementsByName(
      followerCountNodeName
    );
    const followerCountNodesExist = followerCountNodes.length > 0;
    const channelNameAnchorNode = document.querySelector(
      selectors.channelNameAnchorNode
    );
    if (channelNameAnchorNode) {
      const anchorHref = channelNameAnchorNode.getAttribute("href");
      if (anchorHref && anchorHref.length > 1) {
        const channelNameFromAnchor = anchorHref.substr(1);
        if (
          currentChannel.name !== channelNameFromAnchor ||
          !followerCountNodesExist
        ) {
          followerCountNodes.forEach((fcNode) =>
            fcNode.classList.add("updating-counter")
          );
          currentChannel.nameShown = channelNameNode.innerText;
          currentChannel.name = channelNameFromAnchor;
          getFollowerCount()
            .then((response) => handleFollowerCountAPIResponse(response))
            .catch((error) => {
              console.error(error);
            });
        }
      }
    }
  }
};

const removeExistingFollowerCountNodes = () => {
  const followerCountNodes = document.getElementsByName(followerCountNodeName);
  followerCountNodes.forEach((fcNode) => fcNode.remove());
};

const getFollowerCount = async () => {
  const url = "https://gql.twitch.tv/gql";
  const requestBody = JSON.stringify([
    {
      operationName: "ChannelPage_ChannelFollowerCount",
      variables: {
        login: currentChannel.name,
      },
      extensions: {
        persistedQuery: {
          version: 1,
          sha256Hash:
            "87f496584ac60bcfb00db2ce59054b73155f297f1796e5e2418d685213233ad9",
        },
      },
    },
  ]);
  const followerCountResponse = await fetch(url, {
    method: "POST",
    headers: {
      "Content-type": "application/json",
      "Client-Id": "kimne78kx3ncx6brgo4mv6wki5h1ko",
    },
    body: requestBody,
  });
  if (followerCountResponse.ok) {
    let followerCountResponseBody = await followerCountResponse.json();
    return followerCountResponseBody;
  } else {
    console.error(followerCountResponse);
    const errorMessage = `Endpoint responded with status: ${followerCountResponse.status}`;
    throw new Error(errorMessage);
  }
};

const responseIsValid = (response) => {
  return (
    Array.isArray(response) &&
    response.length > 0 &&
    "data" in response[0] &&
    "user" in response[0].data &&
    "followers" in response[0].data.user &&
    "totalCount" in response[0].data.user.followers &&
    response[0].data.user.followers.totalCount !== null &&
    response[0].data.user.followers.totalCount !== undefined
  );
};

const handleFollowerCountAPIResponse = (response) => {
  if (!responseIsValid(response)) {
    console.error(response);
    const errorMessage = "Invalid response body";
    throw new Error(errorMessage);
  }
  currentChannel.followers = response[0].data.user.followers.totalCount;
  removeExistingFollowerCountNodes();
  insertFollowerCountNode();
  monitorTitle();
};

const monitorTitle = () => {
  if (titleObserver) {
    titleObserver.disconnect();
  }
  titleObserver = new MutationObserver(() => handleTitleChange());
  titleObserver.observe(document.querySelector("title"), {
    subtree: true,
    characterData: true,
    childList: true,
  });
};

const handleTitleChange = () => {
  const isChannelPage = document.querySelector(selectors.channelNameNode);
  if (isChannelPage) {
    const matches = channelPageTitleRegExp.exec(document.title);
    if (matches && matches.length > 1) {
      const channelNameFromTitle = matches[1];
      const channelHasChanged =
        currentChannel.nameShown &&
        channelNameFromTitle.toLowerCase() !==
          currentChannel.nameShown.toLowerCase();
      if (channelHasChanged) {
        run();
      }
    }
  }
};

const insertFollowerCountNode = () => {
  const channelNameNode = document.querySelector(selectors.channelNameNode);
  channelNameNode.style.display = "inline-block";
  const channelPartnerBadgeNode = document.querySelector(
    selectors.channelPartnerBadgeNode
  );
  const followerCountNode = createFollowerCountNode();
  if (config.insertNextToFollowButton) {
    const divWithButtonsRowNode = document.querySelector(
      selectors.divWithButtonsRowNode
    );
    const followerCountContainerNode =
      createFollowerCountContainerNode(followerCountNode);
    divWithButtonsRowNode.insertBefore(
      followerCountContainerNode,
      divWithButtonsRowNode.firstChild
    );
  } else if (channelPartnerBadgeNode) {
    channelPartnerBadgeNode.parentNode.insertBefore(
      followerCountNode,
      channelPartnerBadgeNode.nextSibling
    );
  } else {
    channelNameNode.parentNode.insertBefore(
      followerCountNode,
      channelNameNode.nextSibling
    );
  }
};

const createFollowerCountNode = () => {
  const followerCountTextNode = createFollowerCountTextNode();
  const followerCountNode = document.createElement("h2");
  followerCountNode.setAttribute("name", followerCountNodeName);
  followerCountNode.classList.add(
    "tw-c-text-alt-2",
    `tw-font-size-${config.fontSize}`,
    "tw-semibold"
  );
  followerCountNode.style.marginLeft = "1rem";
  followerCountNode.style.marginRight = "1rem";
  followerCountNode.style.display = "inline-block";
  followerCountNode.appendChild(followerCountTextNode);
  return followerCountNode;
};

const createFollowerCountTextNode = () => {
  let followersText = currentChannel.followers;
  if (config.localeString) {
    followersText = Number(followersText).toLocaleString();
  }
  if (config.enclosed) {
    followersText = `(${followersText})`;
  }
  return document.createTextNode(followersText);
};

const createFollowerCountContainerNode = (followerCountNode) => {
  const followerCountContainerNode = document.createElement("div");
  followerCountContainerNode.style.display = "flex";
  followerCountContainerNode.style.justifyContent = "center";
  followerCountContainerNode.style.alignContent = "center";
  followerCountContainerNode.style.flexDirection = "column";
  followerCountContainerNode.appendChild(followerCountNode);
  return followerCountContainerNode;
};

(() => {
  console.log("Twitch Follower Count userscript - START");
  try {
    run();
    setInterval(() => run(), pollingInterval);
  } catch (e) {
    console.log("Twitch Follower Count userscript - STOP (ERROR) \n", e);
  }
})();