Strips trackers from YouTube, Amazon, and other links, marks cleaned links with a shiny ✔, and keeps your web clean without clipboard nonsense. 💪
// ==UserScript==
// @name Link Cleaner
// @namespace https://github.com/Justn/link-cleaner
// @version 4.1
// @description Strips trackers from YouTube, Amazon, and other links, marks cleaned links with a shiny ✔, and keeps your web clean without clipboard nonsense. 💪
// @author Justn
// @match *://*/*
// @match file:///*
// @include *
// @exclude *://stumblechat.com/*
// @grant none
// @locale en
// @license MIT
// @icon https://www.google.com/favicon.ico
// @noframes
// @run-at document-idle
// ==/UserScript==
(function () {
'use strict';
// Do not clean links on YouTube or Amazon themselves
if (/youtube\.com/i.test(location.hostname) || /amazon\./i.test(location.hostname)) {
return;
}
// Trackers to strip
const trackerPattern = /([?&])(si=[^&]*|utm_[^&]*|fbclid=[^&]*|gclid=[^&]*|msclkid=[^&]*|twclid=[^&]*|igshid=[^&]*|mc_eid=[^&]*)/gi;
// Core URL cleaning logic
function cleanUrl(url) {
if (!url) return { cleaned: url, reason: null };
let reasons = [];
let cleaned = url;
const original = url;
// Strip trackers
const trackers = [];
cleaned = cleaned.replace(trackerPattern, (match, sep, keyval) => {
trackers.push(keyval.split("=")[0]);
return "";
});
cleaned = cleaned.replace(/[?&]+$/, "");
if (trackers.length > 0) {
trackers.forEach(t => reasons.push("Removed " + t));
}
// Skip Discord media / Tenor
if (/^https?:\/\/(cdn\.discordapp\.com|media\.discordapp\.net|tenor\.com)/i.test(original)) {
return { cleaned: original, reason: null };
}
// Skip YouTube clips
if (/^https?:\/\/(www\.)?youtube\.com\/clip\//i.test(original)) {
return { cleaned: original, reason: null };
}
// Shorts → watch
const shortsMatch = cleaned.match(/^https?:\/\/(www\.)?youtube\.com\/shorts\/([A-Za-z0-9_-]+)/i);
if (shortsMatch) {
cleaned = `https://www.youtube.com/watch?v=${shortsMatch[2]}`;
reasons.push("Expanded shorts → watch");
}
// youtu.be → expand
const shortMatch = cleaned.match(/^https?:\/\/youtu\.be\/([A-Za-z0-9_-]+)/i);
if (shortMatch) {
cleaned = `https://www.youtube.com/watch?v=${shortMatch[1]}`;
reasons.push("Expanded youtu.be → watch");
}
// Playlist
const playlistMatch = cleaned.match(/^https?:\/\/(www\.)?youtube\.com\/playlist\?list=([A-Za-z0-9_-]+)/i);
if (playlistMatch) {
const listId = playlistMatch[2];
cleaned = `https://www.youtube.com/playlist?list=${listId}`;
}
// Watch normalization
const watchMatch = original.match(/^https?:\/\/(www\.)?youtube\.com\/watch\?v=([A-Za-z0-9_-]+)/i);
if (watchMatch) {
const baseUrl = `https://www.youtube.com/watch?v=${watchMatch[2]}`;
const trackersRemoved = trackers.length > 0;
if (cleaned.startsWith(baseUrl) && cleaned !== original && !trackersRemoved) {
reasons.push("Normalized watch URL");
}
}
// Amazon cleanup
if (/^https?:\/\/(www\.)?amazon\./i.test(cleaned)) {
const asinMatch = cleaned.match(/\/(dp|gp\/product)\/([A-Z0-9]{10})/i);
if (asinMatch) {
const newUrl = `https://www.amazon.com/dp/${asinMatch[2]}/`;
if (newUrl !== cleaned) {
cleaned = newUrl;
reasons.push("Amazon shortened to ASIN");
}
}
}
if (reasons.length > 0 && cleaned !== original) {
return { cleaned, reason: reasons.join(", ") };
} else {
return { cleaned: original, reason: null };
}
}
// Checkmark on-page (de-duped)
function markCheck(el, reason) {
if (el.dataset.linkCleaned === "true") return;
// Remove accidental old checkmark siblings
if (el.nextSibling && el.nextSibling.classList && el.nextSibling.classList.contains("link-clean-check")) {
el.parentNode.removeChild(el.nextSibling);
}
const check = document.createElement("span");
check.textContent = " ✔";
check.style.color = "#FFD700";
check.style.fontSize = "smaller";
check.className = "link-clean-check";
check.title = reason || "Cleaned";
el.parentNode.insertBefore(check, el.nextSibling);
el.dataset.linkCleaned = "true";
}
// Sweep DOM, skip editable/input areas, de-dupe checkmarks
function cleanLinks() {
// Anchors
document.querySelectorAll('a[href]').forEach(a => {
if (a.dataset.linkCleaned === "true") return;
const oldHref = a.href;
const result = cleanUrl(oldHref);
if (!result.reason) return;
if (a.getAttribute("data-role") === "img") return;
if (result.cleaned !== oldHref) {
a.href = result.cleaned;
if (a.innerText.includes(oldHref)) {
a.innerText = a.innerText.replace(oldHref, result.cleaned);
}
if (!a.querySelector("img")) {
markCheck(a, result.reason);
}
}
});
// Raw text nodes
const walker = document.createTreeWalker(document.body, NodeFilter.SHOW_TEXT, null, false);
let node;
while ((node = walker.nextNode())) {
// Don’t mess with inputs, textareas, or contentEditable
if (node.parentNode.closest("input, textarea, [contenteditable]")) continue;
if (!node.nodeValue.includes("http")) continue;
if (node.parentNode.dataset.linkCleaned === "true") continue;
const urlRegex = /(https?:\/\/[^\s]+)/gi;
let changed = false;
let reasonStore = null;
const newText = node.nodeValue.replace(urlRegex, (match) => {
const res = cleanUrl(match);
if (res.reason) {
changed = true;
reasonStore = res.reason;
}
return res.cleaned;
});
if (changed) {
const span = document.createElement("span");
span.textContent = newText;
node.parentNode.replaceChild(span, node);
markCheck(span, reasonStore);
span.dataset.linkCleaned = "true";
}
}
}
cleanLinks();
setInterval(cleanLinks, 2000);
// ---- MutationObserver for ProtonMail dynamic content ----
(function () {
// Only run on ProtonMail
if (!/mail\.proton\.me/i.test(location.hostname)) return;
// Make sure the DOM is ready
function startObserver() {
try {
// Pick a root element that contains the email content
// ProtonMail changes layouts, but this usually works:
const targetNode = document.querySelector('.message-content') || document.body;
if (!targetNode) return;
const observer = new MutationObserver((mutationsList) => {
// Whenever the DOM changes, clean links!
cleanLinks();
});
observer.observe(targetNode, {
childList: true,
subtree: true,
});
console.log("Link Cleaner MutationObserver attached in ProtonMail");
} catch (err) {
console.log("Link Cleaner MutationObserver ERROR:", err);
}
}
// Run ASAP, but also on DOMContentLoaded just in case
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', startObserver);
} else {
startObserver();
}
})();
})();