Last active
June 29, 2026 09:41
-
-
Save BlueBeret/1a692a84937e31f75efceb4e9cf610ba to your computer and use it in GitHub Desktop.
Hide blue checkmark (verified) posts on X/Twitter - Tampermonkey userscript
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| // ==UserScript== | |
| // @name Hide Blue Checkmark Posts (X / Twitter) | |
| // @namespace https://gist.github.com/BlueBeret/1a692a84937e31f75efceb4e9cf610ba | |
| // @version 1.1.0 | |
| // @description Hide posts from verified (blue checkmark) users on X/Twitter timelines, optionally keeping accounts you follow | |
| // @author BlueBeret | |
| // @match https://x.com/* | |
| // @match https://twitter.com/* | |
| // @run-at document-start | |
| // @grant none | |
| // ==/UserScript== | |
| (function () { | |
| 'use strict'; | |
| // ----- settings ----- | |
| // ONLY_BLUE: true -> hide only the blue (Premium) checkmark, skip gold (business) and gray (government) | |
| // ONLY_BLUE: false -> hide any verified badge | |
| const ONLY_BLUE = true; | |
| // COLLAPSE: true -> leave a small "hidden" placeholder, false -> fully remove the post from view | |
| const COLLAPSE = true; | |
| // EXCLUDE_FOLLOWING: true -> never hide posts from accounts you follow, even if blue-check. | |
| // followed handles are harvested automatically from X's own API responses and cached in localStorage. | |
| const EXCLUDE_FOLLOWING = true; | |
| // optional manual keep-list, merged with the auto-detected following list (lowercase handles, no @) | |
| const ALWAYS_KEEP = new Set([ | |
| // 'somefriend', | |
| ]); | |
| // blue checkmark fill color used by X (brand blue). gold/gray badges use other colors. | |
| const BLUE_COLORS = new Set(['rgb(29, 155, 240)', 'rgb(29,155,240)']); | |
| // --------------------------------------------------------------------------- | |
| // following list: harvested from X's GraphQL/API traffic + cached | |
| // --------------------------------------------------------------------------- | |
| const LS_KEY = 'bluecheck_following_v1'; | |
| const TTL = 7 * 24 * 60 * 60 * 1000; // refresh continuously from traffic; cache just bootstraps fast | |
| const followed = new Set(ALWAYS_KEEP); | |
| function loadFollowed() { | |
| try { | |
| const raw = localStorage.getItem(LS_KEY); | |
| if (!raw) return; | |
| const { ts, handles } = JSON.parse(raw); | |
| if (Date.now() - ts > TTL) return; // stale, will rebuild from live traffic | |
| handles.forEach((h) => followed.add(h)); | |
| } catch (e) {} | |
| } | |
| function saveFollowed() { | |
| try { | |
| localStorage.setItem( | |
| LS_KEY, | |
| JSON.stringify({ ts: Date.now(), handles: [...followed] }) | |
| ); | |
| } catch (e) {} | |
| } | |
| // add a screen_name, return true if it was new | |
| function addFollowed(sn) { | |
| const h = String(sn).toLowerCase(); | |
| if (followed.has(h)) return false; | |
| followed.add(h); | |
| return true; | |
| } | |
| // walk any JSON blob looking for user objects flagged as followed. | |
| // handles the various shapes X uses (following on the node, on legacy, or on relationship_perspectives) | |
| function harvest(data) { | |
| let added = false; | |
| const stack = [data]; | |
| let steps = 0; | |
| while (stack.length && steps < 50000) { | |
| const node = stack.pop(); | |
| steps++; | |
| if (!node || typeof node !== 'object') continue; | |
| const sn = | |
| node.screen_name || | |
| (node.core && node.core.screen_name) || | |
| (node.legacy && node.legacy.screen_name); | |
| const fl = | |
| node.following === true || | |
| (node.legacy && node.legacy.following === true) || | |
| (node.relationship_perspectives && | |
| node.relationship_perspectives.following === true); | |
| if (sn && fl && addFollowed(sn)) added = true; | |
| if (Array.isArray(node)) { | |
| for (const v of node) if (v && typeof v === 'object') stack.push(v); | |
| } else { | |
| for (const k in node) { | |
| const v = node[k]; | |
| if (v && typeof v === 'object') stack.push(v); | |
| } | |
| } | |
| } | |
| return added; | |
| } | |
| function harvestText(url, text) { | |
| if (!text || !/x\.com|twitter\.com/.test(url)) return; | |
| // cheap pre-filter before parsing | |
| if (text.indexOf('screen_name') < 0 || text.indexOf('following') < 0) return; | |
| let json; | |
| try { | |
| json = JSON.parse(text); | |
| } catch (e) { | |
| return; | |
| } | |
| if (harvest(json)) scheduleRescan(); | |
| } | |
| // ----- intercept fetch ----- | |
| const origFetch = window.fetch; | |
| if (origFetch) { | |
| window.fetch = function (...args) { | |
| return origFetch.apply(this, args).then((res) => { | |
| try { | |
| const url = | |
| (res && res.url) || | |
| (typeof args[0] === 'string' ? args[0] : args[0] && args[0].url) || | |
| ''; | |
| if (/graphql|\/i\/api/.test(url)) { | |
| res | |
| .clone() | |
| .text() | |
| .then((t) => harvestText(url, t)) | |
| .catch(() => {}); | |
| } | |
| } catch (e) {} | |
| return res; | |
| }); | |
| }; | |
| } | |
| // ----- intercept XHR ----- | |
| const origOpen = XMLHttpRequest.prototype.open; | |
| const origSend = XMLHttpRequest.prototype.send; | |
| XMLHttpRequest.prototype.open = function (method, url, ...rest) { | |
| this.__bc_url = url; | |
| return origOpen.call(this, method, url, ...rest); | |
| }; | |
| XMLHttpRequest.prototype.send = function (...a) { | |
| this.addEventListener('load', function () { | |
| try { | |
| const url = this.__bc_url || ''; | |
| if (!/graphql|\/i\/api/.test(url)) return; | |
| if (this.responseType === 'json') { | |
| if (this.response && harvest(this.response)) scheduleRescan(); | |
| } else if (this.responseType === '' || this.responseType === 'text') { | |
| harvestText(url, this.responseText); | |
| } | |
| } catch (e) {} | |
| }); | |
| return origSend.apply(this, a); | |
| }; | |
| // pull the @handle for a tweet from its permalink (most reliable), with a text fallback | |
| function getHandle(article) { | |
| const timeLink = | |
| (article.querySelector('a[href*="/status/"] time') || {}).parentElement || | |
| article.querySelector('a[href*="/status/"]'); | |
| const href = timeLink && timeLink.getAttribute('href'); | |
| const m = href && href.match(/^\/([^/]+)\/status\//); | |
| if (m) return m[1].toLowerCase(); | |
| const nameBlock = article.querySelector('[data-testid="User-Name"]'); | |
| if (nameBlock) { | |
| for (const s of nameBlock.querySelectorAll('span')) { | |
| if (/^@\w{1,15}$/.test(s.textContent)) return s.textContent.slice(1).toLowerCase(); | |
| } | |
| } | |
| return null; | |
| } | |
| function isFollowed(article) { | |
| if (!EXCLUDE_FOLLOWING) return false; | |
| const h = getHandle(article); | |
| return !!h && followed.has(h); | |
| } | |
| // --------------------------------------------------------------------------- | |
| // badge detection (unchanged) | |
| // --------------------------------------------------------------------------- | |
| // returns true if the badge inside the author area is a blue checkmark | |
| function isBlueBadge(badge) { | |
| if (!ONLY_BLUE) return true; | |
| // gold business badges contain a gradient; blue is a solid fill | |
| if (badge.querySelector('linearGradient')) return false; | |
| const colored = badge.querySelector('[fill]') || badge; | |
| const fill = | |
| colored.getAttribute('fill') || | |
| getComputedStyle(colored).fill || | |
| getComputedStyle(badge).color; | |
| return BLUE_COLORS.has(fill) || fill === '#1d9bf0'; | |
| } | |
| // a post is the tweet article; the badge must sit in the author name block | |
| function hasBlueCheck(article) { | |
| const nameBlock = article.querySelector('[data-testid="User-Name"]') || article; | |
| const badge = nameBlock.querySelector('svg[data-testid="icon-verified"]'); | |
| if (!badge) return false; | |
| return isBlueBadge(badge); | |
| } | |
| // --------------------------------------------------------------------------- | |
| // hide / unhide | |
| // --------------------------------------------------------------------------- | |
| const hiddenState = new WeakMap(); // article -> { el, note } | |
| function hidePost(article) { | |
| if (article.dataset.bluecheckHidden) return; | |
| article.dataset.bluecheckHidden = '1'; | |
| const cell = article.closest('[data-testid="cellInnerDiv"]'); | |
| if (COLLAPSE) { | |
| // collapse the cell but keep a tiny note so the timeline doesn't jump | |
| article.style.display = 'none'; | |
| const note = document.createElement('div'); | |
| note.className = 'bluecheck-note'; | |
| note.textContent = 'Post hidden (blue checkmark)'; | |
| note.style.cssText = | |
| 'padding:8px 16px;font-size:13px;color:#71767b;border-bottom:1px solid #2f3336;'; | |
| (cell || article).appendChild(note); | |
| hiddenState.set(article, { el: article, note }); | |
| } else { | |
| const el = cell || article; | |
| el.style.display = 'none'; | |
| hiddenState.set(article, { el, note: null }); | |
| } | |
| } | |
| // reverse a hide when we later learn the author is followed (or it no longer qualifies) | |
| function unhidePost(article) { | |
| if (!article.dataset.bluecheckHidden) return; | |
| delete article.dataset.bluecheckHidden; | |
| const st = hiddenState.get(article); | |
| if (st) { | |
| if (st.el) st.el.style.display = ''; | |
| if (st.note) st.note.remove(); | |
| hiddenState.delete(article); | |
| } else { | |
| article.style.display = ''; | |
| } | |
| } | |
| function processArticle(a) { | |
| const blue = hasBlueCheck(a); | |
| const keep = blue && isFollowed(a); | |
| if (blue && !keep) hidePost(a); | |
| else if (a.dataset.bluecheckHidden) unhidePost(a); | |
| } | |
| function scan(root) { | |
| const articles = root.querySelectorAll | |
| ? root.querySelectorAll('article[data-testid="tweet"]') | |
| : []; | |
| articles.forEach(processArticle); | |
| // also handle the root itself if it's an article | |
| if (root.matches && root.matches('article[data-testid="tweet"]')) { | |
| processArticle(root); | |
| } | |
| } | |
| // debounced rescan, fired whenever new follow data is learned | |
| let rescanQueued = false; | |
| function scheduleRescan() { | |
| if (rescanQueued) return; | |
| rescanQueued = true; | |
| setTimeout(() => { | |
| rescanQueued = false; | |
| saveFollowed(); | |
| scan(document); | |
| }, 200); | |
| } | |
| // --------------------------------------------------------------------------- | |
| // boot | |
| // --------------------------------------------------------------------------- | |
| loadFollowed(); | |
| function start() { | |
| scan(document); | |
| // the timeline streams in new posts, so watch for them | |
| const observer = new MutationObserver((mutations) => { | |
| for (const m of mutations) { | |
| for (const node of m.addedNodes) { | |
| if (node.nodeType === 1) scan(node); | |
| } | |
| } | |
| }); | |
| observer.observe(document.body, { childList: true, subtree: true }); | |
| } | |
| // @run-at document-start lets us hook fetch/XHR before X loads; wait for body to scan | |
| if (document.body) start(); | |
| else document.addEventListener('DOMContentLoaded', start, { once: true }); | |
| })(); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment