Skip to content

Instantly share code, notes, and snippets.

@litui
Last active July 24, 2025 21:52
Show Gist options
  • Select an option

  • Save litui/2b1c037a8c9a7c78b32a0dfc33c7efbb to your computer and use it in GitHub Desktop.

Select an option

Save litui/2b1c037a8c9a7c78b32a0dfc33c7efbb to your computer and use it in GitHub Desktop.
Streamer Text Filtering Tampermonkey Script
// ==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