NOTICE: By continued use of this site you understand and agree to the binding Terms of Service and Privacy Policy.
// ==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); } })();