Last active
July 24, 2025 21:52
-
-
Save litui/2b1c037a8c9a7c78b32a0dfc33c7efbb to your computer and use it in GitHub Desktop.
Streamer Text Filtering Tampermonkey Script
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 Streamer Text Filtering | |
| // @namespace https://litui.net | |
| // @version 2025-07-23 | |
| // @description not perfect, but this aims to keep streamers safer by filtering out specific regular expressions | |
| // @author Aria Burrell <litui@litui.ca> | |
| // @match *://*/* | |
| // @icon https://www.google.com/s2/favicons?sz=64&domain=undefined.ai | |
| // @grant unsafeWindow | |
| // @run-at document-start | |
| // ==/UserScript== | |
| // This class gets added to checked elements to avoid repetition | |
| const redactedItemClass = "stf-redacted-item"; | |
| const hiddenElementClass = "stf-hidden"; | |
| const replacedImageClass = "stf-replaced-image"; | |
| const imageAncestorClass = "stf-image-ancestor"; | |
| // These are appended to the id and name on clones of original form elements | |
| const hiddenElementIdSuffix = "-stf-redacted"; | |
| const hiddenElementNameSuffix = "-stf-redacted"; | |
| const riPrefix = ""; | |
| const riSuffix = " [R]"; | |
| const replacementImageText = "[R]"; | |
| // This will need to be adjusted depending on the length of the above text: | |
| const replacementImageFontPercent = 0.5; | |
| const replacementImageBackgroundColour = "#ffefadff" | |
| const replacementImageForegroundColour = "#000000ff" | |
| // Because of the point in time this script intervenes in the DOM, image width/height aren't always set. | |
| // These are fallback values. Adjust to preference. | |
| const replacementImageFallbackWidth = 50; | |
| const replacementImageFallbackHeight = 50; | |
| // String or regex list (regexes must be global (/g) and multiline (/m) ) | |
| // Replacement can be a text string or a function. | |
| const textFilterList = [ | |
| { | |
| // When full name appears | |
| filter: /aria burrell/igm, | |
| // Replacement values. Regex group references work here: | |
| replacement: "Litui", | |
| // The following values are optional to direct the profile image replacer: | |
| replaceNearbyImages: true, | |
| imageAncestryDistance: 2, // descendants of the same grandparent | |
| }, | |
| { | |
| // when first name appears as the only thing on a line | |
| filter: /^aria$/igm, | |
| // Replacement values. Regex group references work here: | |
| replacement: "Litui", | |
| // The following values are optional to direct the profile image replacer: | |
| replaceNearbyImages: true, | |
| imageAncestryDistance: 2, // descendants of the same grandparent | |
| }, | |
| // Convenience substitution function example (vibe coder -> wank coder, retaining case) | |
| // affixes must be explicitly included in your function's return string if you want them. | |
| { | |
| filter: /vibe (code|coding|coder)/igm, | |
| replacement: function(fullMatch, match1) { return maintainTextCase("wank", fullMatch) + ` ${match1}`; }, | |
| }, | |
| // Email addresses (doesn't work with certain js obscuring techniques (eg: on Google) | |
| { | |
| filter: /[\w\-\.]+@([\w\-]+\.)+[\w\-]{2,4}/igm, | |
| replacement: "Email address", | |
| }, | |
| // OpenID addresses | |
| { | |
| filter: /oidc@[\w\-\.]+/gm, | |
| replacement: "OpenID address", | |
| }, | |
| // IPv4 Address: | |
| { | |
| filter: /((25[0-5]|(2[0-4]|1\d|[1-9]|)\d)\.?\b){4}/gm, | |
| replacement: "IP address", | |
| }, | |
| ]; | |
| const textFilterAttributes = [ | |
| "aria-label", | |
| "label", | |
| "alt", | |
| ]; | |
| // borrowed from https://stackoverflow.com/a/17265031 | |
| function maintainTextCase(sourceText, pattern) { | |
| var result = ''; | |
| for(var i = 0; i < sourceText.length; i++) { | |
| var c = sourceText.charAt(i); | |
| var p = pattern.charCodeAt(i); | |
| // ASCII-wise checking/setting of existing case | |
| if(p >= 65 && p < 65 + 26) { | |
| result += c.toUpperCase(); | |
| } else { | |
| result += c.toLowerCase(); | |
| } | |
| } | |
| return result; | |
| } | |
| function makeSvg(imageWidth, imageHeight) { | |
| const width = imageWidth || replacementImageFallbackWidth; | |
| const height = imageHeight || replacementImageFallbackHeight; | |
| const replacementImage = `<?xml version="1.0" standalone="no"?> | |
| <svg version="1.0" xmlns="http://www.w3.org/2000/svg" | |
| width="${width}" height="${height}" viewBox="0 0 ${width} ${height}"> | |
| <rect x="0" y="0" width="${width}" height="${height}" fill="${replacementImageBackgroundColour}"/> | |
| <text x="${Math.round(width/2)}" y="${Math.round(height/2)}" | |
| text-anchor="middle" | |
| dominant-baseline="middle" | |
| fill="${replacementImageForegroundColour}" | |
| font-size="${Math.round(height*replacementImageFontPercent)}" | |
| font-family="Verdana,Arial,Helvetica,Sans">${replacementImageText}</text> | |
| </svg> | |
| `; | |
| return `data:image/svg+xml;base64,${btoa(replacementImage)}`; | |
| } | |
| // Recursive function to ascend the DOM tree and return the ancestor at specificed depth/distance | |
| // if the tree is too short for specified depth/distance, returns the ancestor at the farthest depth | |
| function getAncestorElement(element, depth) { | |
| const parent = element ? element.parentElement : null; | |
| const d = depth - 1 | |
| return parent ? (d ? getAncestorElement(parent, d) : parent) : element; | |
| } | |
| // Recursive function to ascend the DOM tree in search of an ancestor with imageAncestorClass. | |
| // Returns null if there is none. | |
| function getTaggedAncestor(element) { | |
| if (element) { | |
| if (element.classList.contains(imageAncestorClass)) { | |
| return element; | |
| } | |
| return getTaggedAncestor(element.parentElement); | |
| } | |
| return null; | |
| } | |
| // Recursive function to descend the DOM tree from a specified element in search of images to replace the src of. | |
| function tagChildImagesForReplacement(element) { | |
| for (var i = 0; i < element.children.length; i++) { | |
| var node = element.children[i]; | |
| if (node) { | |
| if (node.tagName === "IMG" && !node.classList.contains(replacedImageClass)) { | |
| node.alt = `${riPrefix}${node.alt}${riSuffix}`; | |
| node.src = makeSvg(node.width, node.height); | |
| node.srcset = "" | |
| node.classList.add(replacedImageClass); | |
| } | |
| tagChildImagesForReplacement(node); | |
| } | |
| } | |
| } | |
| function prepareReplacement(filterRe) { | |
| const prefix = filterRe.suppressAffixes ? "" : riPrefix; | |
| const suffix = filterRe.suppressAffixes ? "" : riSuffix; | |
| return filterRe.replacement ? (typeof filterRe.replacement === "function" ? filterRe.replacement : `${prefix}${filterRe.replacement}${suffix}`) : ""; | |
| } | |
| // Callback function to review and make changes to the observed DOM tree | |
| function check(changes, observer) { | |
| changes.forEach((chg) => { | |
| if (chg.type == "attributes") { | |
| // Persist replaced images (set again if something else changes the src) | |
| var node = chg.target; | |
| if (!node) { | |
| return; | |
| } | |
| textFilterAttributes.forEach((filtAttr) => { | |
| if (chg.attributeName.includes(filtAttr)) { | |
| textFilterList.forEach((filterRe) => { | |
| const replacement = prepareReplacement(filterRe); | |
| node.attributes[filtAttr] = node.ariaLabel.replaceAll(filterRe.filter, replacement); | |
| console.log(node.attributes[filtAttr]); | |
| }); | |
| } | |
| }); | |
| if (node.classList.contains(replacedImageClass)) { | |
| if (chg.attributeName === "src" && (node && node.tagName === "IMG")) { | |
| const genImg = makeSvg(node.width, node.height); | |
| if (node.src !== genImg) { | |
| node.src = genImg; | |
| node.srcset = "" | |
| } | |
| } | |
| } | |
| } | |
| if (chg.type == "childList") { | |
| chg.addedNodes.forEach((node) => { | |
| if (node.nodeType === 1) { // DOM elements | |
| if (node.classList.contains(hiddenElementClass)) { | |
| return; | |
| } | |
| // Watch for new/changed images and check if they have a tagged ancestor for filtering purposes | |
| if (node.tagName === "IMG") { | |
| if(getTaggedAncestor(node.parentElement)) { | |
| const genImg = makeSvg(node.width, node.height); | |
| if (node.src !== genImg) { | |
| node.src = genImg; | |
| if (!node.classList.contains(replacedImageClass)) { | |
| node.alt = `${riPrefix}${node.alt}${riSuffix}`; | |
| node.classList.add(replacedImageClass); | |
| } | |
| } | |
| } | |
| } | |
| textFilterList.forEach((filterRe) => { | |
| // Search up filter item first. Will result in a double-search (via later replaceAll) but prevents adding | |
| // the redactedItemClass to every single element. | |
| if (node.value && (filterRe.filter && node.value.search(filterRe.filter) > -1)) { | |
| const replacement = prepareReplacement(filterRe); | |
| var newNode = node.cloneNode(true); | |
| // Hide original node | |
| node.style = "display: none;"; | |
| node.classList.add(hiddenElementClass); | |
| // Modify new node to be a dud/stand-in | |
| newNode.id = newNode.id + hiddenElementIdSuffix; | |
| newNode.name = ""; | |
| newNode.disabled = true; | |
| newNode.value = newNode.value.replaceAll(filterRe.filter, replacement); | |
| newNode.classList.add(redactedItemClass); | |
| // Append new node so it appears in order | |
| node.after(newNode); | |
| } | |
| }); | |
| } else if (node.nodeType === 3) { // DOM text | |
| if (node.parentElement && node.parentElement.classList.contains(redactedItemClass)) { | |
| // return; | |
| } | |
| textFilterList.forEach((filterRe) => { | |
| // Search up filter item first. Will result in a double-search (via later replaceAll) but prevents adding | |
| // the redactedItemClass to every single element. | |
| if (filterRe.filter && node.data.search(filterRe.filter) > -1) { | |
| const replacement = prepareReplacement(filterRe); | |
| node.data = node.data.replaceAll(filterRe.filter, replacement); | |
| node.parentElement.classList.add(redactedItemClass); | |
| if (filterRe.replaceNearbyImages) { | |
| const distance = filterRe.imageAncestryDistance || 1; | |
| const ancestor = getAncestorElement(node.parentElement, distance) | |
| tagChildImagesForReplacement(ancestor); | |
| ancestor.parentElement.classList.add(imageAncestorClass); | |
| } | |
| } | |
| }); | |
| } | |
| }); | |
| } | |
| }); | |
| }; | |
| (function() { | |
| 'use strict'; | |
| (new MutationObserver(check)).observe(document, {childList: true, subtree: true, attributes: true}); | |
| })(); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment