Created
May 22, 2026 14:42
-
-
Save LawrenceHwang/9ee7401d2c4e32857cd623b86fface6f to your computer and use it in GitHub Desktop.
UserScript to clean up tracking, unwanted parameters. To be used with extension such as TamperMonkey
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 URL Clean | |
| // @namespace https://github.com/ | |
| // @version 0.1.7 | |
| // @description Cleans safe tracking parameters and known redirect wrappers on an allowlisted set of video, shopping, search, and social sites. | |
| // @match https://*.youtube.com/* | |
| // @match https://youtu.be/* | |
| // @match https://*.twitter.com/* | |
| // @match https://x.com/* | |
| // @match https://*.facebook.com/* | |
| // @match https://*.instagram.com/* | |
| // @match https://*.threads.net/* | |
| // @match https://*.linkedin.com/* | |
| // @match https://*.reddit.com/* | |
| // @match https://*.tiktok.com/* | |
| // @match https://*.snapchat.com/* | |
| // @match https://*.messenger.com/* | |
| // @match https://t.me/* | |
| // @match https://telegram.me/* | |
| // @match https://*.whatsapp.com/* | |
| // @match https://*.pinterest.com/* | |
| // @match https://*.tumblr.com/* | |
| // @match https://vk.com/* | |
| // @match https://news.ycombinator.com/* | |
| // @match https://*.bing.com/* | |
| // @match https://*.msn.com/* | |
| // @match https://google.com/search* | |
| // @match https://www.google.com/search* | |
| // @match https://amazon.com/* | |
| // @match https://*.amazon.com/* | |
| // @match https://amazon.ca/* | |
| // @match https://*.amazon.ca/* | |
| // @match https://amazon.co.uk/* | |
| // @match https://*.amazon.co.uk/* | |
| // @match https://amazon.de/* | |
| // @match https://*.amazon.de/* | |
| // @match https://amazon.fr/* | |
| // @match https://*.amazon.fr/* | |
| // @match https://amazon.es/* | |
| // @match https://*.amazon.es/* | |
| // @match https://amazon.it/* | |
| // @match https://*.amazon.it/* | |
| // @match https://amazon.co.jp/* | |
| // @match https://*.amazon.co.jp/* | |
| // @match https://amazon.com.au/* | |
| // @match https://*.amazon.com.au/* | |
| // @match https://amazon.in/* | |
| // @match https://*.amazon.in/* | |
| // @match https://amazon.com.br/* | |
| // @match https://*.amazon.com.br/* | |
| // @match https://amazon.com.mx/* | |
| // @match https://*.amazon.com.mx/* | |
| // @match https://amazon.nl/* | |
| // @match https://*.amazon.nl/* | |
| // @match https://amazon.se/* | |
| // @match https://*.amazon.se/* | |
| // @match https://amazon.pl/* | |
| // @match https://*.amazon.pl/* | |
| // @match https://amazon.sg/* | |
| // @match https://*.amazon.sg/* | |
| // @match https://amazon.ae/* | |
| // @match https://*.amazon.ae/* | |
| // @match https://amazon.sa/* | |
| // @match https://*.amazon.sa/* | |
| // @match https://amazon.eg/* | |
| // @match https://*.amazon.eg/* | |
| // @match https://amazon.com.tr/* | |
| // @match https://*.amazon.com.tr/* | |
| // @match https://amazon.be/* | |
| // @match https://*.amazon.be/* | |
| // @match https://ebay.com/* | |
| // @match https://*.ebay.com/* | |
| // @match https://ebay.ca/* | |
| // @match https://*.ebay.ca/* | |
| // @match https://ebay.co.uk/* | |
| // @match https://*.ebay.co.uk/* | |
| // @match https://ebay.de/* | |
| // @match https://*.ebay.de/* | |
| // @match https://ebay.fr/* | |
| // @match https://*.ebay.fr/* | |
| // @match https://ebay.es/* | |
| // @match https://*.ebay.es/* | |
| // @match https://ebay.it/* | |
| // @match https://*.ebay.it/* | |
| // @match https://ebay.com.au/* | |
| // @match https://*.ebay.com.au/* | |
| // @run-at document-start | |
| // @grant none | |
| // @noframes | |
| // ==/UserScript== | |
| (() => { | |
| "use strict"; | |
| // @grant none is intentional: the injected bridge relies on patching the same page-facing browser APIs that the userscript sees. | |
| const DEBUG = false; | |
| const DIRECT_URL_PATTERN = /^(https?:\/\/|mailto:)/i; | |
| const INPUT_TYPES_WITH_SELECTION = new Set(["text", "search", "url", "email", "tel", "password"]); | |
| const SHAREISH_PATTERN = /\b(share|copy(?:[-_\s]?link|[-_\s]?url)?|clipboard)\b/i; | |
| const SHARE_DATA_ATTRIBUTES = [ | |
| "data-url", | |
| "data-href", | |
| "data-share-url", | |
| "data-permalink", | |
| "data-link", | |
| "data-copy-link", | |
| "data-copy-url", | |
| ]; | |
| const MAX_SANITIZE_PASSES = 6; | |
| // Fixed bridge names are intentional for now; nonce hardening can be added later if real page-script interference shows up. | |
| const SANITIZE_BRIDGE_EVENT = "url-cleaner:sanitize-text"; | |
| const SANITIZE_BRIDGE_INPUT_ATTRIBUTE = "data-url-cleaner-sanitize-input"; | |
| const SANITIZE_BRIDGE_OUTPUT_ATTRIBUTE = "data-url-cleaner-sanitize-output"; | |
| const SANITIZE_BRIDGE_CONTEXT_ATTRIBUTE = "data-url-cleaner-sanitize-context"; | |
| const SANITIZE_BRIDGE_INSTALLED_ATTRIBUTE = "data-url-cleaner-sanitize-bridge-installed"; | |
| const SANITIZE_BRIDGE_STATE_USERSCRIPT = "userscript"; | |
| const SANITIZE_BRIDGE_STATE_PAGE = "page"; | |
| const SHARE_CLEANUP_PASS_DELAYS = [150, 500]; | |
| const GLOBAL_EXACT_PARAMS = new Set([ | |
| "gclid", | |
| "dclid", | |
| "gbraid", | |
| "wbraid", | |
| "msclkid", | |
| "yclid", | |
| "fbclid", | |
| "mc_cid", | |
| "mc_eid", | |
| "mkt_tok", | |
| "_hsenc", | |
| "_hsmi", | |
| "vero_id", | |
| "vero_conv", | |
| "oly_anon_id", | |
| "oly_enc_id", | |
| "li_fat_id", | |
| "ttclid", | |
| "twclid", | |
| "rb_clickid", | |
| "s_cid", | |
| "epik", | |
| "_gl", | |
| "gad_source", | |
| "gad_campaignid", | |
| "gad_adid", | |
| "igshid", | |
| "mibextid", | |
| "wickedid", | |
| "ef_id", | |
| ]); | |
| const GLOBAL_PARAM_PATTERNS = [ | |
| /^utm_[a-z0-9_]+$/i, | |
| /^mtm_[a-z0-9_]+$/i, | |
| /^ga_[a-z0-9_]+$/i, | |
| /^hsa_[a-z0-9_]+$/i, | |
| ]; | |
| const PRESERVE_PARAM_PATTERNS = [ | |
| /(^|_)(state|nonce|csrf|xsrf|session|signature|sig)(_|$)/i, | |
| /(^|_)(code|token)(_|$)/i, | |
| ]; | |
| const SITE_RULES = [ | |
| { | |
| hostPattern: /(^|\.)youtube\.com$/i, | |
| exactParams: new Set(["feature", "si", "pp", "app"]), | |
| }, | |
| { | |
| hostPattern: /(^|\.)youtu\.be$/i, | |
| exactParams: new Set(["feature", "si", "pp", "app"]), | |
| }, | |
| { | |
| hostPattern: /(^|\.)twitter\.com$/i, | |
| exactParams: new Set(["ref_src", "s", "t", "cn"]), | |
| }, | |
| { | |
| hostPattern: /(^|\.)x\.com$/i, | |
| exactParams: new Set(["ref_src", "s", "t", "cn"]), | |
| }, | |
| { | |
| hostPattern: /(^|\.)instagram\.com$/i, | |
| exactParams: new Set(["igshid", "igsh"]), | |
| }, | |
| { | |
| hostPattern: /(^|\.)tiktok\.com$/i, | |
| exactParams: new Set(["u_code", "preview_pb", "_d", "_t", "_r", "timestamp", "user_id", "share_app_name", "share_iid", "source"]), | |
| }, | |
| { | |
| hostPattern: /(^|\.)snapchat\.com$/i, | |
| exactParams: new Set(["sc_referrer", "sc_referrer_domain", "sc_ua"]), | |
| }, | |
| { | |
| hostPattern: /(^|\.)amazon\./i, | |
| exactParams: new Set(["tag", "linkcode", "ascsubtag", "camp", "creative", "creativeasin"]), | |
| paramPatterns: [/^ref_?$/i, /^pd_rd_/i, /^pf_rd_/i], | |
| pathSanitizer: (pathname) => pathname.replace(/\/ref(?:=[^/?#]*)?(?=\/|$)/i, ""), | |
| }, | |
| { | |
| hostPattern: /(^|\.)ebay\./i, | |
| exactParams: new Set(["_trkparms", "_trksid", "mkcid", "mkrid", "siteid", "campid", "customid", "toolid"]), | |
| }, | |
| { | |
| hostPattern: /(^|\.)linkedin\.com$/i, | |
| exactParams: new Set(["trk", "trackingid", "lipi", "midtoken", "eid", "rcm"]), | |
| }, | |
| { | |
| hostPattern: /(^|\.)bing\.com$/i, | |
| exactParams: new Set(["ocid", "cvid"]), | |
| }, | |
| { | |
| hostPattern: /(^|\.)msn\.com$/i, | |
| exactParams: new Set(["ocid", "cvid"]), | |
| }, | |
| { | |
| hostPattern: /(^|\.)google\./i, | |
| pathPattern: /^\/search$/i, | |
| exactParams: new Set(["source", "sourceid", "ved", "ei", "sa", "oq", "aqs", "sei", "sclient", "uact", "biw", "bih", "iflsig", "sca_esv", "sca_upv"]), | |
| paramPatterns: [/^gs_/i, /^gws_/i], | |
| }, | |
| ]; | |
| const REDIRECT_RULES = [ | |
| { | |
| hostPattern: /(^|\.)youtube\.com$/i, | |
| pathPattern: /^\/redirect$/i, | |
| paramNames: ["q"], | |
| }, | |
| { | |
| hostPattern: /(^|\.)google\./i, | |
| pathPattern: /^\/url$/i, | |
| paramNames: ["q", "url"], | |
| }, | |
| { | |
| hostPattern: /(^|\.)facebook\.com$/i, | |
| pathPattern: /^\/l\.php$/i, | |
| paramNames: ["u"], | |
| }, | |
| { | |
| hostPattern: /(^|\.)messenger\.com$/i, | |
| pathPattern: /^\/l\.php$/i, | |
| paramNames: ["u"], | |
| }, | |
| { | |
| hostPattern: /^out\.reddit\.com$/i, | |
| paramNames: ["url"], | |
| }, | |
| { | |
| hostPattern: /^t\.umblr\.com$/i, | |
| pathPattern: /^\/redirect$/i, | |
| paramNames: ["z"], | |
| }, | |
| { | |
| hostPattern: /^vk\.com$/i, | |
| pathPattern: /^\/away\.php$/i, | |
| paramNames: ["to"], | |
| }, | |
| ]; | |
| const SHARE_SERVICES = [ | |
| { | |
| hostPattern: /(^|\.)twitter\.com$/i, | |
| pathPattern: /^\/intent\/tweet$/i, | |
| urlParams: ["url"], | |
| textParams: ["text"], | |
| }, | |
| { | |
| hostPattern: /(^|\.)x\.com$/i, | |
| pathPattern: /^\/intent\/tweet$/i, | |
| urlParams: ["url"], | |
| textParams: ["text"], | |
| }, | |
| { | |
| hostPattern: /(^|\.)facebook\.com$/i, | |
| pathPattern: /^\/(?:sharer(?:\.php)?|dialog\/share)$/i, | |
| urlParams: ["u", "href"], | |
| }, | |
| { | |
| hostPattern: /(^|\.)linkedin\.com$/i, | |
| pathPattern: /^\/(?:sharing\/share-offsite|shareArticle)\/?$/i, | |
| urlParams: ["url"], | |
| textParams: ["summary"], | |
| }, | |
| { | |
| hostPattern: /(^|\.)reddit\.com$/i, | |
| pathPattern: /^\/submit$/i, | |
| urlParams: ["url"], | |
| textParams: ["title", "text"], | |
| }, | |
| { | |
| hostPattern: /(^|\.)t\.me$/i, | |
| pathPattern: /^\/share\/url$/i, | |
| urlParams: ["url"], | |
| textParams: ["text"], | |
| }, | |
| { | |
| hostPattern: /(^|\.)telegram\.me$/i, | |
| pathPattern: /^\/share\/url$/i, | |
| urlParams: ["url"], | |
| textParams: ["text"], | |
| }, | |
| { | |
| hostPattern: /(^|\.)whatsapp\.com$/i, | |
| pathPattern: /^\/send$/i, | |
| textParams: ["text"], | |
| }, | |
| { | |
| hostPattern: /(^|\.)api\.whatsapp\.com$/i, | |
| pathPattern: /^\/send$/i, | |
| textParams: ["text"], | |
| }, | |
| { | |
| hostPattern: /(^|\.)pinterest\./i, | |
| pathPattern: /^\/pin\/create\/button\/?$/i, | |
| urlParams: ["url"], | |
| textParams: ["description"], | |
| }, | |
| { | |
| hostPattern: /(^|\.)tumblr\.com$/i, | |
| pathPattern: /^\/widgets\/share\/tool$/i, | |
| urlParams: ["canonicalUrl", "url"], | |
| textParams: ["caption", "content"], | |
| }, | |
| { | |
| hostPattern: /(^|\.)vk\.com$/i, | |
| pathPattern: /^\/share\.php$/i, | |
| urlParams: ["url"], | |
| textParams: ["title", "comment"], | |
| }, | |
| { | |
| hostPattern: /(^|\.)news\.ycombinator\.com$/i, | |
| pathPattern: /^\/submitlink$/i, | |
| urlParams: ["u"], | |
| textParams: ["t"], | |
| }, | |
| ]; | |
| const nativeHistoryPushState = typeof history.pushState === "function" ? history.pushState.bind(history) : null; | |
| const nativeHistoryReplaceState = typeof history.replaceState === "function" ? history.replaceState.bind(history) : null; | |
| const nativeDataTransferSetData = typeof DataTransfer !== "undefined" && DataTransfer.prototype && typeof DataTransfer.prototype.setData === "function" | |
| ? DataTransfer.prototype.setData | |
| : null; | |
| let isApplyingInternalHistoryCleanup = false; | |
| let shareCleanupObserver = null; | |
| let shareCleanupAnimationFrameId = null; | |
| let shareCleanupTimeoutIds = []; | |
| let shareCleanupPending = false; | |
| function logDebug(message, details) { | |
| if (!DEBUG) { | |
| return; | |
| } | |
| console.debug("[url-cleaner]", message, details || ""); | |
| } | |
| function pushUnique(list, value) { | |
| if (value && !list.includes(value)) { | |
| list.push(value); | |
| } | |
| } | |
| function tryPatchMethod(target, methodName, createReplacement) { | |
| if (!target || typeof target[methodName] !== "function") { | |
| return false; | |
| } | |
| const originalMethod = target[methodName].bind(target); | |
| const replacement = createReplacement(originalMethod); | |
| try { | |
| target[methodName] = replacement; | |
| return target[methodName] === replacement; | |
| } catch { | |
| try { | |
| Object.defineProperty(target, methodName, { | |
| configurable: true, | |
| writable: true, | |
| value: replacement, | |
| }); | |
| return true; | |
| } catch { | |
| return false; | |
| } | |
| } | |
| } | |
| function getElementDescriptorText(element) { | |
| if (!(element instanceof Element)) { | |
| return ""; | |
| } | |
| const textParts = [ | |
| element.getAttribute("aria-label"), | |
| element.getAttribute("title"), | |
| element.getAttribute("id"), | |
| element.getAttribute("class"), | |
| element.getAttribute("data-action"), | |
| element.getAttribute("data-testid"), | |
| element.getAttribute("role"), | |
| ]; | |
| if (element instanceof HTMLButtonElement || element instanceof HTMLAnchorElement) { | |
| const textContent = (element.textContent || "").trim().replace(/\s+/g, " "); | |
| if (textContent && textContent.length <= 80) { | |
| textParts.push(textContent); | |
| } | |
| } | |
| return textParts.filter(Boolean).join(" "); | |
| } | |
| function isShareishElement(element) { | |
| return SHAREISH_PATTERN.test(getElementDescriptorText(element)); | |
| } | |
| function isPreservedParam(paramName) { | |
| return PRESERVE_PARAM_PATTERNS.some((pattern) => pattern.test(paramName)); | |
| } | |
| function isGlobalTrackingParam(paramName) { | |
| if (GLOBAL_EXACT_PARAMS.has(paramName)) { | |
| return true; | |
| } | |
| return GLOBAL_PARAM_PATTERNS.some((pattern) => pattern.test(paramName)); | |
| } | |
| function isGoogleSearchPage() { | |
| return /(^|\.)google\.com$/i.test(location.hostname) && /^\/search$/i.test(location.pathname); | |
| } | |
| function getMatchingSiteRules(url) { | |
| const host = url.hostname; | |
| const path = url.pathname; | |
| return SITE_RULES.filter((rule) => { | |
| if (!rule.hostPattern.test(host)) { | |
| return false; | |
| } | |
| if (rule.pathPattern && !rule.pathPattern.test(path)) { | |
| return false; | |
| } | |
| return true; | |
| }); | |
| } | |
| function getMatchingRedirectRules(url) { | |
| return REDIRECT_RULES.filter((rule) => { | |
| if (!rule.hostPattern.test(url.hostname)) { | |
| return false; | |
| } | |
| if (rule.pathPattern && !rule.pathPattern.test(url.pathname)) { | |
| return false; | |
| } | |
| return true; | |
| }); | |
| } | |
| function isSiteTrackingParam(paramName, rules) { | |
| for (const rule of rules) { | |
| if (rule.exactParams && rule.exactParams.has(paramName)) { | |
| return true; | |
| } | |
| if (rule.paramPatterns && rule.paramPatterns.some((pattern) => pattern.test(paramName))) { | |
| return true; | |
| } | |
| } | |
| return false; | |
| } | |
| function sanitizeHash(hash) { | |
| if (!hash || hash === "#") { | |
| return { hash, removed: [] }; | |
| } | |
| const rawHash = hash.slice(1); | |
| if (!rawHash || rawHash.includes("/") || rawHash.startsWith("!")) { | |
| return { hash, removed: [] }; | |
| } | |
| const isQueryStyle = rawHash.startsWith("?") || rawHash.includes("="); | |
| if (!isQueryStyle) { | |
| return { hash, removed: [] }; | |
| } | |
| const hashParams = new URLSearchParams(rawHash.startsWith("?") ? rawHash.slice(1) : rawHash); | |
| const removed = []; | |
| for (const key of new Set(hashParams.keys())) { | |
| const lowerKey = key.toLowerCase(); | |
| if (isPreservedParam(lowerKey)) { | |
| continue; | |
| } | |
| if (!isGlobalTrackingParam(lowerKey)) { | |
| continue; | |
| } | |
| hashParams.delete(key); | |
| removed.push(key); | |
| } | |
| if (!removed.length) { | |
| return { hash, removed }; | |
| } | |
| const remaining = hashParams.toString(); | |
| if (!remaining) { | |
| return { hash: "", removed }; | |
| } | |
| const prefix = rawHash.startsWith("?") ? "#?" : "#"; | |
| return { hash: `${prefix}${remaining}`, removed }; | |
| } | |
| function decodeUrlCandidate(value) { | |
| let candidate = value.trim(); | |
| for (let pass = 0; pass < MAX_SANITIZE_PASSES; pass += 1) { | |
| let decoded; | |
| try { | |
| decoded = decodeURIComponent(candidate); | |
| } catch { | |
| break; | |
| } | |
| if (decoded === candidate) { | |
| break; | |
| } | |
| candidate = decoded; | |
| } | |
| return candidate; | |
| } | |
| function extractRedirectTarget(url) { | |
| const redirectRules = getMatchingRedirectRules(url); | |
| for (const rule of redirectRules) { | |
| for (const paramName of rule.paramNames || []) { | |
| const rawValue = url.searchParams.get(paramName); | |
| if (!rawValue) { | |
| continue; | |
| } | |
| const candidate = decodeUrlCandidate(rawValue); | |
| let targetUrl; | |
| try { | |
| targetUrl = new URL(candidate, url.origin); | |
| } catch { | |
| continue; | |
| } | |
| if (!/^https?:$/i.test(targetUrl.protocol)) { | |
| continue; | |
| } | |
| const targetUrlString = targetUrl.toString(); | |
| if (targetUrlString === url.toString()) { | |
| continue; | |
| } | |
| return { | |
| changed: true, | |
| url: targetUrlString, | |
| removed: [`redirect:${paramName}`], | |
| requiresNavigation: true, | |
| }; | |
| } | |
| } | |
| return null; | |
| } | |
| function sanitizeUrlPass(rawUrl, context) { | |
| if (typeof rawUrl !== "string") { | |
| return { changed: false, url: rawUrl, removed: [], requiresNavigation: false }; | |
| } | |
| const trimmed = rawUrl.trim(); | |
| if (!trimmed || !DIRECT_URL_PATTERN.test(trimmed)) { | |
| return { changed: false, url: rawUrl, removed: [], requiresNavigation: false }; | |
| } | |
| let url; | |
| try { | |
| url = new URL(trimmed); | |
| } catch { | |
| return { changed: false, url: rawUrl, removed: [], requiresNavigation: false }; | |
| } | |
| if (!/^https?:$/i.test(url.protocol)) { | |
| return { changed: false, url: rawUrl, removed: [], requiresNavigation: false }; | |
| } | |
| const redirectResult = extractRedirectTarget(url); | |
| if (redirectResult) { | |
| logDebug("Unwrapped redirect", { | |
| context, | |
| original: rawUrl, | |
| cleaned: redirectResult.url, | |
| removed: redirectResult.removed, | |
| }); | |
| return redirectResult; | |
| } | |
| const rules = getMatchingSiteRules(url); | |
| const removed = []; | |
| const searchKeys = Array.from(new Set(url.searchParams.keys())); | |
| for (const key of searchKeys) { | |
| const lowerKey = key.toLowerCase(); | |
| if (isPreservedParam(lowerKey)) { | |
| continue; | |
| } | |
| if (!isGlobalTrackingParam(lowerKey) && !isSiteTrackingParam(lowerKey, rules)) { | |
| continue; | |
| } | |
| url.searchParams.delete(key); | |
| pushUnique(removed, key); | |
| } | |
| const originalPathname = url.pathname; | |
| let pathname = originalPathname.replace(/;jsessionid=[^/?#;]*/i, ""); | |
| for (const rule of rules) { | |
| if (typeof rule.pathSanitizer === "function") { | |
| const sanitizedPathname = rule.pathSanitizer(pathname); | |
| if (typeof sanitizedPathname === "string") { | |
| pathname = sanitizedPathname || "/"; | |
| } | |
| } | |
| } | |
| if (pathname !== originalPathname) { | |
| url.pathname = pathname; | |
| pushUnique(removed, "pathname"); | |
| } | |
| const nextHash = sanitizeHash(url.hash); | |
| if (nextHash.hash !== url.hash) { | |
| url.hash = nextHash.hash; | |
| for (const removedHashKey of nextHash.removed) { | |
| pushUnique(removed, `hash:${removedHashKey}`); | |
| } | |
| } | |
| if (!removed.length) { | |
| return { changed: false, url: rawUrl, removed: [], requiresNavigation: false }; | |
| } | |
| const cleanedUrl = url.toString(); | |
| logDebug("Sanitized URL pass", { | |
| context, | |
| original: rawUrl, | |
| cleaned: cleanedUrl, | |
| removed, | |
| }); | |
| return { | |
| changed: cleanedUrl !== rawUrl, | |
| url: cleanedUrl, | |
| removed, | |
| requiresNavigation: false, | |
| }; | |
| } | |
| function sanitizeUrlString(rawUrl, context) { | |
| if (typeof rawUrl !== "string") { | |
| return { changed: false, url: rawUrl, removed: [], requiresNavigation: false }; | |
| } | |
| let currentUrl = rawUrl.trim(); | |
| if (!currentUrl || !DIRECT_URL_PATTERN.test(currentUrl)) { | |
| return { changed: false, url: rawUrl, removed: [], requiresNavigation: false }; | |
| } | |
| const removed = []; | |
| let requiresNavigation = false; | |
| for (let pass = 0; pass < MAX_SANITIZE_PASSES; pass += 1) { | |
| const passResult = sanitizeUrlPass(currentUrl, `${context || "url"}:pass-${pass + 1}`); | |
| if (!passResult.changed || passResult.url === currentUrl) { | |
| break; | |
| } | |
| currentUrl = passResult.url; | |
| requiresNavigation = requiresNavigation || passResult.requiresNavigation; | |
| for (const removedEntry of passResult.removed) { | |
| pushUnique(removed, removedEntry); | |
| } | |
| } | |
| if (!removed.length || currentUrl === rawUrl) { | |
| return { changed: false, url: rawUrl, removed: [], requiresNavigation: false }; | |
| } | |
| logDebug("Sanitized URL", { | |
| context, | |
| original: rawUrl, | |
| cleaned: currentUrl, | |
| removed, | |
| requiresNavigation, | |
| }); | |
| return { | |
| changed: true, | |
| url: currentUrl, | |
| removed, | |
| requiresNavigation, | |
| }; | |
| } | |
| function toPreferredRelativeUrl(rawUrl, cleanedUrl, baseUrl) { | |
| if (typeof rawUrl !== "string" || !baseUrl) { | |
| return null; | |
| } | |
| let base; | |
| let resolvedUrl; | |
| try { | |
| base = new URL(baseUrl); | |
| resolvedUrl = cleanedUrl instanceof URL ? cleanedUrl : new URL(String(cleanedUrl), base); | |
| } catch { | |
| return null; | |
| } | |
| if (resolvedUrl.origin !== base.origin) { | |
| return null; | |
| } | |
| const trimmedRawUrl = rawUrl.trim(); | |
| if (!trimmedRawUrl || DIRECT_URL_PATTERN.test(trimmedRawUrl)) { | |
| return null; | |
| } | |
| if (trimmedRawUrl.startsWith("#") && resolvedUrl.pathname === base.pathname && resolvedUrl.search === base.search) { | |
| return resolvedUrl.hash || "#"; | |
| } | |
| if (trimmedRawUrl.startsWith("?") && resolvedUrl.pathname === base.pathname) { | |
| return `${resolvedUrl.search}${resolvedUrl.hash}` || base.pathname; | |
| } | |
| return `${resolvedUrl.pathname}${resolvedUrl.search}${resolvedUrl.hash}`; | |
| } | |
| function sanitizeResolvableUrl(rawUrl, baseUrl, context) { | |
| if (typeof rawUrl !== "string" && !(rawUrl instanceof URL)) { | |
| return { changed: false, url: rawUrl, removed: [], requiresNavigation: false }; | |
| } | |
| const rawText = String(rawUrl).trim(); | |
| if (!rawText) { | |
| return { changed: false, url: rawUrl, removed: [], requiresNavigation: false }; | |
| } | |
| if (DIRECT_URL_PATTERN.test(rawText)) { | |
| return sanitizeUrlString(rawText, context); | |
| } | |
| if (!baseUrl) { | |
| return { changed: false, url: rawUrl, removed: [], requiresNavigation: false }; | |
| } | |
| let resolvedUrl; | |
| try { | |
| resolvedUrl = new URL(rawText, baseUrl).toString(); | |
| } catch { | |
| return { changed: false, url: rawUrl, removed: [], requiresNavigation: false }; | |
| } | |
| const result = sanitizeUrlString(resolvedUrl, context); | |
| if (!result.changed) { | |
| return { changed: false, url: rawUrl, removed: [], requiresNavigation: false }; | |
| } | |
| const relativeUrl = toPreferredRelativeUrl(rawText, result.url, baseUrl); | |
| if (!relativeUrl) { | |
| return result; | |
| } | |
| return { | |
| ...result, | |
| url: relativeUrl, | |
| }; | |
| } | |
| function createEmbeddedUrlPattern() { | |
| return /https?:\/\/[^\s<>"'`\])]+/gi; | |
| } | |
| function replaceEmbeddedUrls(text, context) { | |
| if (typeof text !== "string" || !text) { | |
| return { changed: false, text, removed: [] }; | |
| } | |
| let changed = false; | |
| const removed = []; | |
| const replacedText = text.replace(createEmbeddedUrlPattern(), (match) => { | |
| const result = sanitizeUrlString(match, context); | |
| if (!result.changed) { | |
| return match; | |
| } | |
| changed = true; | |
| removed.push(...result.removed); | |
| return result.url; | |
| }); | |
| return { | |
| changed, | |
| text: replacedText, | |
| removed, | |
| }; | |
| } | |
| function getShareService(url) { | |
| if (/^mailto:$/i.test(url.protocol)) { | |
| return { | |
| urlParams: [], | |
| textParams: ["body"], | |
| }; | |
| } | |
| return SHARE_SERVICES.find((service) => { | |
| if (!service.hostPattern.test(url.hostname)) { | |
| return false; | |
| } | |
| if (service.pathPattern && !service.pathPattern.test(url.pathname)) { | |
| return false; | |
| } | |
| return true; | |
| }) || null; | |
| } | |
| function sanitizeShareLauncherUrl(rawUrl, context) { | |
| if (typeof rawUrl !== "string") { | |
| return { changed: false, url: rawUrl, removed: [] }; | |
| } | |
| let url; | |
| try { | |
| url = new URL(rawUrl, location.href); | |
| } catch { | |
| return { changed: false, url: rawUrl, removed: [] }; | |
| } | |
| const shareService = getShareService(url); | |
| if (!shareService) { | |
| return { changed: false, url: rawUrl, removed: [] }; | |
| } | |
| const removed = []; | |
| const urlParams = shareService.urlParams || []; | |
| const textParams = shareService.textParams || []; | |
| for (const paramName of urlParams) { | |
| const value = url.searchParams.get(paramName); | |
| if (!value) { | |
| continue; | |
| } | |
| const result = sanitizeUrlString(value, `${context || "share"}:${paramName}`); | |
| if (!result.changed) { | |
| continue; | |
| } | |
| url.searchParams.set(paramName, result.url); | |
| removed.push(`${paramName}:${result.removed.join(",")}`); | |
| } | |
| for (const paramName of textParams) { | |
| const value = url.searchParams.get(paramName); | |
| if (!value) { | |
| continue; | |
| } | |
| const result = replaceEmbeddedUrls(value, `${context || "share"}:${paramName}`); | |
| if (!result.changed) { | |
| continue; | |
| } | |
| url.searchParams.set(paramName, result.text); | |
| removed.push(`${paramName}:embedded-url`); | |
| } | |
| if (!removed.length) { | |
| return { changed: false, url: rawUrl, removed: [] }; | |
| } | |
| const cleanedShareUrl = url.toString(); | |
| logDebug("Sanitized share launcher", { | |
| context, | |
| original: rawUrl, | |
| cleaned: cleanedShareUrl, | |
| removed, | |
| }); | |
| return { | |
| changed: cleanedShareUrl !== rawUrl, | |
| url: cleanedShareUrl, | |
| removed, | |
| }; | |
| } | |
| function cleanCurrentPageUrl(context) { | |
| if (!nativeHistoryReplaceState || isApplyingInternalHistoryCleanup) { | |
| return false; | |
| } | |
| const result = sanitizeUrlString(location.href, context); | |
| if (!result.changed || result.url === location.href) { | |
| return false; | |
| } | |
| if (result.requiresNavigation) { | |
| window.location.replace(result.url); | |
| return true; | |
| } | |
| try { | |
| isApplyingInternalHistoryCleanup = true; | |
| nativeHistoryReplaceState(history.state, "", result.url); | |
| logDebug("Sanitized current page URL", { | |
| context, | |
| original: location.href, | |
| cleaned: result.url, | |
| removed: result.removed, | |
| }); | |
| return true; | |
| } catch { | |
| return false; | |
| } finally { | |
| isApplyingInternalHistoryCleanup = false; | |
| } | |
| } | |
| function sanitizeReadonlyUrlField(field, context) { | |
| if (!(field instanceof HTMLInputElement || field instanceof HTMLTextAreaElement)) { | |
| return false; | |
| } | |
| const isReadonlyLike = field.readOnly || field.disabled || field.getAttribute("aria-readonly") === "true"; | |
| if (!isReadonlyLike) { | |
| return false; | |
| } | |
| const value = field.value.trim(); | |
| if (!DIRECT_URL_PATTERN.test(value)) { | |
| return false; | |
| } | |
| const result = sanitizeUrlString(value, context); | |
| if (!result.changed) { | |
| return false; | |
| } | |
| field.value = result.url; | |
| if (field.hasAttribute("value")) { | |
| field.setAttribute("value", result.url); | |
| } | |
| logDebug("Sanitized readonly URL field", { | |
| context, | |
| original: value, | |
| cleaned: result.url, | |
| removed: result.removed, | |
| }); | |
| return true; | |
| } | |
| function getDeepActiveElement(root) { | |
| let activeElement = root && "activeElement" in root ? root.activeElement : null; | |
| while (activeElement && activeElement.shadowRoot && activeElement.shadowRoot.activeElement) { | |
| activeElement = activeElement.shadowRoot.activeElement; | |
| } | |
| return activeElement; | |
| } | |
| function clearQueuedShareCleanupPasses() { | |
| if (shareCleanupAnimationFrameId !== null) { | |
| window.cancelAnimationFrame(shareCleanupAnimationFrameId); | |
| shareCleanupAnimationFrameId = null; | |
| } | |
| for (const timeoutId of shareCleanupTimeoutIds) { | |
| window.clearTimeout(timeoutId); | |
| } | |
| shareCleanupTimeoutIds = []; | |
| } | |
| function stopShareCleanupObserver() { | |
| if (!shareCleanupObserver) { | |
| return; | |
| } | |
| shareCleanupObserver.disconnect(); | |
| shareCleanupObserver = null; | |
| } | |
| function runShareCleanup(root) { | |
| shareCleanupPending = false; | |
| return sanitizeShareNodes(root); | |
| } | |
| function scheduleDeferredShareCleanup() { | |
| const runIfNeeded = () => { | |
| if (!shareCleanupPending) { | |
| return; | |
| } | |
| shareCleanupPending = runShareCleanup(document); | |
| }; | |
| shareCleanupAnimationFrameId = window.requestAnimationFrame(() => { | |
| shareCleanupAnimationFrameId = null; | |
| runIfNeeded(); | |
| }); | |
| shareCleanupTimeoutIds = SHARE_CLEANUP_PASS_DELAYS.map((delay, index) => window.setTimeout(() => { | |
| runIfNeeded(); | |
| if (index === SHARE_CLEANUP_PASS_DELAYS.length - 1) { | |
| stopShareCleanupObserver(); | |
| } | |
| }, delay)); | |
| } | |
| function queueShareCleanupPasses() { | |
| clearQueuedShareCleanupPasses(); | |
| stopShareCleanupObserver(); | |
| shareCleanupPending = runShareCleanup(document); | |
| shareCleanupObserver = new MutationObserver((mutations) => { | |
| if (mutations.some((mutation) => mutation.type === "childList" && (mutation.addedNodes.length || mutation.removedNodes.length))) { | |
| shareCleanupPending = true; | |
| } | |
| }); | |
| shareCleanupObserver.observe(document.documentElement, { | |
| childList: true, | |
| subtree: true, | |
| }); | |
| scheduleDeferredShareCleanup(); | |
| } | |
| function stripAnchorTrackingAttributes(anchor) { | |
| if (!(anchor instanceof HTMLAnchorElement)) { | |
| return false; | |
| } | |
| let changed = false; | |
| if (anchor.hasAttribute("ping")) { | |
| anchor.removeAttribute("ping"); | |
| changed = true; | |
| } | |
| if (isGoogleSearchPage() && anchor.hasAttribute("data-cthref")) { | |
| anchor.removeAttribute("data-cthref"); | |
| changed = true; | |
| } | |
| return changed; | |
| } | |
| function sanitizeAnchorHref(anchor, context) { | |
| if (!(anchor instanceof HTMLAnchorElement)) { | |
| return false; | |
| } | |
| const href = anchor.getAttribute("href"); | |
| if (!href) { | |
| return false; | |
| } | |
| const result = sanitizeResolvableUrl(href, location.href, context); | |
| if (!result.changed) { | |
| return false; | |
| } | |
| anchor.setAttribute("href", result.url); | |
| return true; | |
| } | |
| function isTextInput(element) { | |
| if (!(element instanceof HTMLInputElement || element instanceof HTMLTextAreaElement)) { | |
| return false; | |
| } | |
| if (element instanceof HTMLTextAreaElement) { | |
| return true; | |
| } | |
| return INPUT_TYPES_WITH_SELECTION.has((element.type || "text").toLowerCase()); | |
| } | |
| function getSelectionText() { | |
| const activeElement = getDeepActiveElement(document); | |
| if (isTextInput(activeElement)) { | |
| const start = activeElement.selectionStart; | |
| const end = activeElement.selectionEnd; | |
| if (typeof start === "number" && typeof end === "number" && end > start) { | |
| return activeElement.value.slice(start, end).trim(); | |
| } | |
| } | |
| const selection = document.getSelection(); | |
| if (!selection) { | |
| return ""; | |
| } | |
| return selection.toString().trim(); | |
| } | |
| function sanitizeClipboardPayload(text, context) { | |
| if (typeof text !== "string") { | |
| return { changed: false, text, removed: [] }; | |
| } | |
| const trimmed = text.trim(); | |
| if (DIRECT_URL_PATTERN.test(trimmed)) { | |
| const result = sanitizeUrlString(trimmed, context); | |
| if (result.changed) { | |
| return { | |
| changed: true, | |
| text: result.url, | |
| removed: result.removed, | |
| }; | |
| } | |
| } | |
| const embeddedResult = replaceEmbeddedUrls(text, context); | |
| if (!embeddedResult.changed) { | |
| return { changed: false, text, removed: [] }; | |
| } | |
| return { | |
| changed: true, | |
| text: embeddedResult.text, | |
| removed: embeddedResult.removed, | |
| }; | |
| } | |
| function isTextualClipboardFormat(format) { | |
| const normalizedFormat = String(format || "").toLowerCase(); | |
| return normalizedFormat === "text" || normalizedFormat.startsWith("text/"); | |
| } | |
| function sanitizeClipboardDataValue(format, value, context) { | |
| if (typeof value !== "string") { | |
| return { changed: false, value, removed: [] }; | |
| } | |
| const normalizedFormat = String(format || "").toLowerCase(); | |
| if (normalizedFormat === "text/uri-list") { | |
| let changed = false; | |
| const removed = []; | |
| const nextValue = value | |
| .split(/\r?\n/) | |
| .map((line) => { | |
| if (!line || line.startsWith("#")) { | |
| return line; | |
| } | |
| const result = sanitizeClipboardPayload(line, `${context}:uri-list`); | |
| if (!result.changed) { | |
| return line; | |
| } | |
| changed = true; | |
| removed.push(...result.removed); | |
| return result.text; | |
| }) | |
| .join("\n"); | |
| if (!changed) { | |
| return { changed: false, value, removed: [] }; | |
| } | |
| return { | |
| changed: true, | |
| value: nextValue, | |
| removed, | |
| }; | |
| } | |
| if (!isTextualClipboardFormat(normalizedFormat)) { | |
| return { changed: false, value, removed: [] }; | |
| } | |
| const result = sanitizeClipboardPayload(value, context); | |
| if (!result.changed) { | |
| return { changed: false, value, removed: [] }; | |
| } | |
| return { | |
| changed: true, | |
| value: result.text, | |
| removed: result.removed, | |
| }; | |
| } | |
| function handleCopy(event) { | |
| if (!event.clipboardData || event.defaultPrevented) { | |
| return; | |
| } | |
| const selectedText = getSelectionText(); | |
| if (!selectedText) { | |
| return; | |
| } | |
| const result = sanitizeClipboardPayload(selectedText, "copy-event"); | |
| if (!result.changed) { | |
| return; | |
| } | |
| const setClipboardData = nativeDataTransferSetData | |
| ? (format, data) => nativeDataTransferSetData.call(event.clipboardData, format, data) | |
| : (format, data) => event.clipboardData.setData(format, data); | |
| setClipboardData("text/plain", result.text); | |
| if (/^https?:\/\//i.test(result.text)) { | |
| setClipboardData("text/uri-list", result.text); | |
| } | |
| event.preventDefault(); | |
| logDebug("Sanitized copied URL", { | |
| original: selectedText, | |
| cleaned: result.text, | |
| removed: result.removed, | |
| }); | |
| } | |
| function patchClipboardWriteText() { | |
| if (!navigator.clipboard || typeof navigator.clipboard.writeText !== "function") { | |
| return; | |
| } | |
| // Keep a direct fallback in the userscript context because some sites block the injected page-world bridge with CSP. | |
| tryPatchMethod(navigator.clipboard, "writeText", (originalWriteText) => (text) => { | |
| const result = sanitizeClipboardPayload(text, "clipboard-writeText"); | |
| return originalWriteText(result.changed ? result.text : text); | |
| }); | |
| } | |
| function patchDataTransferSetData() { | |
| if (typeof DataTransfer === "undefined" || !DataTransfer.prototype || typeof DataTransfer.prototype.setData !== "function") { | |
| return; | |
| } | |
| const originalSetData = DataTransfer.prototype.setData; | |
| const replacement = function (format, data) { | |
| const result = sanitizeClipboardDataValue(format, data, `clipboard.setData:${format || "unknown"}`); | |
| return originalSetData.call(this, format, result.changed ? result.value : data); | |
| }; | |
| try { | |
| DataTransfer.prototype.setData = replacement; | |
| } catch { | |
| try { | |
| Object.defineProperty(DataTransfer.prototype, "setData", { | |
| configurable: true, | |
| writable: true, | |
| value: replacement, | |
| }); | |
| } catch { | |
| return; | |
| } | |
| } | |
| } | |
| function installPageClipboardSanitizer() { | |
| const bridgeRoot = document.documentElement; | |
| const bridgeState = bridgeRoot ? bridgeRoot.getAttribute(SANITIZE_BRIDGE_INSTALLED_ATTRIBUTE) : null; | |
| if (!bridgeRoot || bridgeState === SANITIZE_BRIDGE_STATE_USERSCRIPT || bridgeState === SANITIZE_BRIDGE_STATE_PAGE) { | |
| return; | |
| } | |
| bridgeRoot.setAttribute(SANITIZE_BRIDGE_INSTALLED_ATTRIBUTE, SANITIZE_BRIDGE_STATE_USERSCRIPT); | |
| document.addEventListener( | |
| SANITIZE_BRIDGE_EVENT, | |
| () => { | |
| const currentBridgeRoot = document.documentElement; | |
| if (!currentBridgeRoot) { | |
| return; | |
| } | |
| const encodedInput = currentBridgeRoot.getAttribute(SANITIZE_BRIDGE_INPUT_ATTRIBUTE); | |
| if (typeof encodedInput !== "string") { | |
| return; | |
| } | |
| const context = currentBridgeRoot.getAttribute(SANITIZE_BRIDGE_CONTEXT_ATTRIBUTE) || "page-clipboard"; | |
| let inputText; | |
| try { | |
| inputText = decodeURIComponent(encodedInput); | |
| } catch { | |
| inputText = encodedInput; | |
| } | |
| const result = sanitizeClipboardPayload(inputText, context); | |
| const outputText = result.changed ? result.text : inputText; | |
| currentBridgeRoot.setAttribute(SANITIZE_BRIDGE_OUTPUT_ATTRIBUTE, encodeURIComponent(outputText)); | |
| }, | |
| true | |
| ); | |
| // The injected page-world patch keeps its own tiny helpers because this source runs outside the userscript closure. | |
| injectPageScript(`(() => { | |
| "use strict"; | |
| const eventName = ${JSON.stringify(SANITIZE_BRIDGE_EVENT)}; | |
| const inputAttribute = ${JSON.stringify(SANITIZE_BRIDGE_INPUT_ATTRIBUTE)}; | |
| const outputAttribute = ${JSON.stringify(SANITIZE_BRIDGE_OUTPUT_ATTRIBUTE)}; | |
| const contextAttribute = ${JSON.stringify(SANITIZE_BRIDGE_CONTEXT_ATTRIBUTE)}; | |
| const markerAttribute = ${JSON.stringify(SANITIZE_BRIDGE_INSTALLED_ATTRIBUTE)}; | |
| const markerPageState = ${JSON.stringify(SANITIZE_BRIDGE_STATE_PAGE)}; | |
| const bridgeRoot = document.documentElement; | |
| if (!bridgeRoot || bridgeRoot.getAttribute(markerAttribute) === markerPageState) { | |
| return; | |
| } | |
| bridgeRoot.setAttribute(markerAttribute, markerPageState); | |
| const requestSanitizedText = (text, context) => { | |
| if (typeof text !== "string") { | |
| return text; | |
| } | |
| const activeBridgeRoot = document.documentElement; | |
| if (!activeBridgeRoot) { | |
| return text; | |
| } | |
| activeBridgeRoot.setAttribute(inputAttribute, encodeURIComponent(text)); | |
| activeBridgeRoot.setAttribute(contextAttribute, context || "page-clipboard"); | |
| activeBridgeRoot.removeAttribute(outputAttribute); | |
| document.dispatchEvent(new CustomEvent(eventName)); | |
| const encodedOutput = activeBridgeRoot.getAttribute(outputAttribute); | |
| activeBridgeRoot.removeAttribute(inputAttribute); | |
| activeBridgeRoot.removeAttribute(outputAttribute); | |
| activeBridgeRoot.removeAttribute(contextAttribute); | |
| if (typeof encodedOutput !== "string") { | |
| return text; | |
| } | |
| try { | |
| return decodeURIComponent(encodedOutput); | |
| } catch { | |
| return text; | |
| } | |
| }; | |
| const tryPatchMethod = (target, methodName, createReplacement) => { | |
| if (!target || typeof target[methodName] !== "function") { | |
| return false; | |
| } | |
| const originalMethod = target[methodName].bind(target); | |
| const replacement = createReplacement(originalMethod); | |
| try { | |
| target[methodName] = replacement; | |
| return target[methodName] === replacement; | |
| } catch { | |
| try { | |
| Object.defineProperty(target, methodName, { | |
| configurable: true, | |
| writable: true, | |
| value: replacement, | |
| }); | |
| return true; | |
| } catch { | |
| return false; | |
| } | |
| } | |
| }; | |
| if (navigator.clipboard && typeof navigator.clipboard.writeText === "function") { | |
| tryPatchMethod(navigator.clipboard, "writeText", (originalWriteText) => (text) => { | |
| const sanitizedText = requestSanitizedText(text, "page-clipboard.writeText"); | |
| return originalWriteText(sanitizedText); | |
| }); | |
| } | |
| const isTextualClipboardFormat = (format) => { | |
| const normalizedFormat = String(format || "").toLowerCase(); | |
| return normalizedFormat === "text" || normalizedFormat.startsWith("text/"); | |
| }; | |
| if (navigator.clipboard && typeof navigator.clipboard.write === "function" && typeof ClipboardItem === "function") { | |
| tryPatchMethod(navigator.clipboard, "write", (originalWrite) => async (items) => { | |
| if (!Array.isArray(items)) { | |
| return originalWrite(items); | |
| } | |
| const nextItems = await Promise.all(items.map(async (item) => { | |
| if (!item || typeof item.getType !== "function" || !Array.isArray(item.types) || !item.types.some((type) => isTextualClipboardFormat(type))) { | |
| return item; | |
| } | |
| const itemPayload = {}; | |
| let changed = false; | |
| for (const type of item.types) { | |
| let blob = await item.getType(type); | |
| if (isTextualClipboardFormat(type) && blob && typeof blob.text === "function") { | |
| const originalText = await blob.text(); | |
| const sanitizedText = requestSanitizedText(originalText, "page-clipboard.write:" + type); | |
| if (sanitizedText !== originalText) { | |
| blob = new Blob([sanitizedText], { type }); | |
| changed = true; | |
| } | |
| } | |
| itemPayload[type] = blob; | |
| } | |
| return changed ? new ClipboardItem(itemPayload) : item; | |
| })); | |
| return originalWrite(nextItems); | |
| }); | |
| } | |
| })();`); | |
| } | |
| function patchNavigatorShare() { | |
| if (typeof navigator.share !== "function") { | |
| return; | |
| } | |
| tryPatchMethod(navigator, "share", (originalShare) => (shareData) => { | |
| if (!shareData || typeof shareData !== "object") { | |
| return originalShare(shareData); | |
| } | |
| const nextShareData = { ...shareData }; | |
| if (typeof nextShareData.url === "string") { | |
| const result = sanitizeUrlString(nextShareData.url, "navigator.share:url"); | |
| if (result.changed) { | |
| nextShareData.url = result.url; | |
| } | |
| } | |
| if (typeof nextShareData.text === "string") { | |
| const result = replaceEmbeddedUrls(nextShareData.text, "navigator.share:text"); | |
| if (result.changed) { | |
| nextShareData.text = result.text; | |
| } | |
| } | |
| return originalShare(nextShareData); | |
| }); | |
| } | |
| function patchWindowOpen() { | |
| if (typeof window.open !== "function") { | |
| return; | |
| } | |
| tryPatchMethod(window, "open", (originalOpen) => (url, target, features) => { | |
| if (typeof url !== "string") { | |
| return originalOpen(url, target, features); | |
| } | |
| const trackingResult = sanitizeResolvableUrl(url, location.href, "window.open:tracking"); | |
| const sanitizedUrl = trackingResult.changed ? trackingResult.url : url; | |
| const shareResult = sanitizeShareLauncherUrl(sanitizedUrl, "window.open:share"); | |
| return originalOpen(shareResult.changed ? shareResult.url : sanitizedUrl, target, features); | |
| }); | |
| } | |
| function patchHistoryState() { | |
| if (nativeHistoryPushState) { | |
| tryPatchMethod(history, "pushState", (originalPushState) => (state, title, url) => { | |
| if (isApplyingInternalHistoryCleanup || typeof url === "undefined" || url === null) { | |
| return originalPushState(state, title, url); | |
| } | |
| const result = sanitizeResolvableUrl(url, location.href, "history.pushState"); | |
| return originalPushState(state, title, result.changed ? result.url : url); | |
| }); | |
| } | |
| if (nativeHistoryReplaceState) { | |
| tryPatchMethod(history, "replaceState", (originalReplaceState) => (state, title, url) => { | |
| if (isApplyingInternalHistoryCleanup || typeof url === "undefined" || url === null) { | |
| return originalReplaceState(state, title, url); | |
| } | |
| const result = sanitizeResolvableUrl(url, location.href, "history.replaceState"); | |
| return originalReplaceState(state, title, result.changed ? result.url : url); | |
| }); | |
| } | |
| } | |
| function sanitizeShareAttributes(element) { | |
| if (!(element instanceof Element)) { | |
| return false; | |
| } | |
| if (!isShareishElement(element)) { | |
| return false; | |
| } | |
| let changed = false; | |
| for (const attributeName of SHARE_DATA_ATTRIBUTES) { | |
| const value = element.getAttribute(attributeName); | |
| if (!value) { | |
| continue; | |
| } | |
| const result = sanitizeUrlString(value, `${attributeName}`); | |
| if (!result.changed) { | |
| continue; | |
| } | |
| element.setAttribute(attributeName, result.url); | |
| changed = true; | |
| } | |
| return changed; | |
| } | |
| function sanitizeShareAnchor(anchor) { | |
| if (!(anchor instanceof HTMLAnchorElement)) { | |
| return false; | |
| } | |
| const href = anchor.getAttribute("href"); | |
| if (!href) { | |
| return false; | |
| } | |
| const result = sanitizeShareLauncherUrl(href, "anchor-href:share"); | |
| if (!result.changed) { | |
| return false; | |
| } | |
| anchor.setAttribute("href", result.url); | |
| return true; | |
| } | |
| function sanitizeInteractiveAnchor(anchor) { | |
| let changed = false; | |
| changed = stripAnchorTrackingAttributes(anchor) || changed; | |
| changed = sanitizeAnchorHref(anchor, "anchor-href:tracking") || changed; | |
| changed = sanitizeShareAnchor(anchor) || changed; | |
| return changed; | |
| } | |
| function sanitizeShareNodes(root) { | |
| if (!(root instanceof Element || root instanceof Document)) { | |
| return false; | |
| } | |
| let changed = false; | |
| const anchors = root.querySelectorAll ? root.querySelectorAll("a[href]") : []; | |
| for (const anchor of anchors) { | |
| changed = sanitizeInteractiveAnchor(anchor) || changed; | |
| } | |
| const attributeSelector = SHARE_DATA_ATTRIBUTES.map((name) => `[${name}]`).join(","); | |
| const candidates = root.querySelectorAll ? root.querySelectorAll(attributeSelector) : []; | |
| for (const candidate of candidates) { | |
| changed = sanitizeShareAttributes(candidate) || changed; | |
| } | |
| const readonlyFieldSelector = [ | |
| "input[readonly]", | |
| "input[disabled]", | |
| "input[aria-readonly='true']", | |
| "textarea[readonly]", | |
| "textarea[disabled]", | |
| "textarea[aria-readonly='true']", | |
| ].join(","); | |
| const readonlyFields = []; | |
| if (root instanceof Element && root.matches(readonlyFieldSelector)) { | |
| readonlyFields.push(root); | |
| } | |
| if (root.querySelectorAll) { | |
| readonlyFields.push(...root.querySelectorAll(readonlyFieldSelector)); | |
| } | |
| for (const field of readonlyFields) { | |
| changed = sanitizeReadonlyUrlField(field, "share-field") || changed; | |
| } | |
| return changed; | |
| } | |
| function injectPageScript(source) { | |
| const script = document.createElement("script"); | |
| script.textContent = source; | |
| const parent = document.documentElement || document.head || document.body; | |
| if (!parent) { | |
| return; | |
| } | |
| parent.appendChild(script); | |
| script.remove(); | |
| } | |
| function installGoogleAntiRewrite() { | |
| if (!isGoogleSearchPage()) { | |
| return; | |
| } | |
| injectPageScript(`(() => { | |
| "use strict"; | |
| const allowClick = () => true; | |
| try { | |
| const descriptor = Object.getOwnPropertyDescriptor(window, "rwt"); | |
| if (!descriptor || descriptor.configurable) { | |
| Object.defineProperty(window, "rwt", { | |
| configurable: false, | |
| enumerable: false, | |
| writable: false, | |
| value: allowClick, | |
| }); | |
| } | |
| } catch (error) { | |
| console.debug("[url-cleaner] Failed to neutralize Google rwt", error); | |
| } | |
| })();`); | |
| } | |
| function handlePotentialAnchorInteraction(event) { | |
| if (!(event.target instanceof Element)) { | |
| return; | |
| } | |
| const anchor = event.target.closest("a[href]"); | |
| if (!anchor) { | |
| return; | |
| } | |
| sanitizeInteractiveAnchor(anchor); | |
| } | |
| function handlePotentialShareInteraction(event) { | |
| if (!(event.target instanceof Element)) { | |
| return; | |
| } | |
| const shareishControl = event.target.closest("button, a, [role='button'], [aria-label], [title], [data-action], [data-testid]"); | |
| if (shareishControl && isShareishElement(shareishControl)) { | |
| queueShareCleanupPasses(); | |
| } | |
| const attributeSelector = SHARE_DATA_ATTRIBUTES.map((name) => `[${name}]`).join(","); | |
| const shareTarget = event.target.closest(attributeSelector); | |
| if (shareTarget) { | |
| sanitizeShareAttributes(shareTarget); | |
| } | |
| } | |
| function startDomShareCleanup() { | |
| document.addEventListener( | |
| "DOMContentLoaded", | |
| () => { | |
| sanitizeShareNodes(document); | |
| }, | |
| { once: true } | |
| ); | |
| } | |
| document.addEventListener("copy", handleCopy, true); | |
| document.addEventListener("mouseover", handlePotentialAnchorInteraction, true); | |
| document.addEventListener("focusin", handlePotentialAnchorInteraction, true); | |
| document.addEventListener("pointerdown", handlePotentialShareInteraction, true); | |
| document.addEventListener("click", handlePotentialShareInteraction, true); | |
| window.addEventListener("popstate", () => { | |
| cleanCurrentPageUrl("popstate"); | |
| }, true); | |
| window.addEventListener("hashchange", () => { | |
| cleanCurrentPageUrl("hashchange"); | |
| }, true); | |
| patchClipboardWriteText(); | |
| patchDataTransferSetData(); | |
| installPageClipboardSanitizer(); | |
| patchNavigatorShare(); | |
| patchWindowOpen(); | |
| patchHistoryState(); | |
| installGoogleAntiRewrite(); | |
| cleanCurrentPageUrl("startup"); | |
| startDomShareCleanup(); | |
| })(); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment