Greasy Fork is available in English.
Remove t.co tracking links from Twitter
// ==UserScript== // @name Twitter Direct // @description Remove t.co tracking links from Twitter // @author chocolateboy // @copyright chocolateboy // @version 3.1.1 // @namespace https://github.com/chocolateboy/userscripts // @license GPL // @include https://mobile.twitter.com/ // @include https://mobile.twitter.com/* // @include https://twitter.com/ // @include https://twitter.com/* // @include https://x.com/ // @include https://x.com/* // @require https://unpkg.com/[email protected]/dist/index.iife.min.js // @run-at document-start // ==/UserScript== // NOTE This file is generated from src/twitter-direct.user.ts and should not be edited directly. "use strict"; (() => { // src/twitter-direct/util.ts var isObject = (value) => !!value && typeof value === "object"; var isPlainObject = function() { const toString = {}.toString; return (value) => toString.call(value) === "[object Object]"; }(); var typeOf = (value) => value === null ? "null" : typeof value; var isType = (type) => { return (value) => { return typeOf(value) === type; }; }; var isString = isType("string"); var isNumber = isType("number"); // src/twitter-direct/replacer.ts var DOCUMENT_ROOTS = [ "data", "globalObjects", "inbox_initial_state", "users" ]; var LEGACY_KEYS = [ "binding_values", "entities", "extended_entities", "full_text", "lang", "quoted_status_permalink", "retweeted_status", "retweeted_status_r###lt", "user_refs" ]; var PRUNE_KEYS = /* @__PURE__ */ new Set([ "advertiser_account_service_levels", "card_platform", "clientEventInfo", "ext", "ext_media_color", "features", "feedbackInfo", "hashtags", "indices", "original_info", "player_image_color", "profile_banner_extensions", "profile_banner_extensions_media_color", "profile_image_extensions", "profile_image_extensions_media_color", "responseObjects", "sizes", "user_mentions", "video_info" ]); var checkUrl = /* @__PURE__ */ function() { const urlPattern = /^https?:\/\/\w/i; return (value) => urlPattern.test(value) && value; }(); var isTrackedUrl = /* @__PURE__ */ function() { const urlPattern = /^https?:\/\/t\.co\/\w+$/; return (value) => urlPattern.test(value); }(); var isURLData = (value) => { return isPlainObject(value) && isString(value.url) && isString(value.expanded_url) && Array.isArray(value.indices) && isNumber(value.indices[0]) && isNumber(value.indices[1]); }; var Replacer = class _Replacer { seen = /* @__PURE__ */ new Map(); unresolved = /* @__PURE__ */ new Map(); count = 0; static transform(data, path) { const replacer = new _Replacer(); return replacer.transform(data, path); } /* * replace t.co URLs with the original URL in all locations in the document * which may contain them * * returns the number of substituted URLs */ transform(data, path) { const { seen, unresolved } = this; if (Array.isArray(data) || "id_str" in data) { this.traverse(data); } else { for (const key of DOCUMENT_ROOTS) { if (key in data) { this.traverse(data[key]); } } } for (const [url, targets] of unresolved) { const expandedUrl = seen.get(url); if (expandedUrl) { for (const { target, key } of targets) { target[key] = expandedUrl; ++this.count; } unresolved.delete(url); } } if (unresolved.size) { console.warn(`unresolved URIs (${path}):`, Object.fromEntries(unresolved)); } return this.count; } /* * reduce the large binding_values array/object to the one property we care * about (card_url) */ onBindingValues(value) { if (Array.isArray(value)) { const found = value.find((it) => it?.key === "card_url"); return found ? [found] : 0; } else if (isPlainObject(value) && isPlainObject(value.card_url)) { return [value.card_url]; } else { return 0; } } /* * handle cases where the t.co URL is already expanded, e.g.: * * { * "entities": { * "urls": [ * { * "display_url": "example.com", * "expanded_url": "https://www.example.com", * "url": "https://www.example.com", * "indices": [16, 39] * } * ] * }, * "full_text": "I'm on the bus! https://t.co/abcde12345" * } * * extract the corresponding t.co URLs from the text via the entities.urls * records and register the t.co -> expanded URL mappings so they can be * used later, e.g. https://t.co/abcde12345 -> https://www.example.com */ onFullText(context, message) { const seen = this.seen; const urls = context.entities?.urls; if (!(Array.isArray(urls) && urls.length)) { return message; } const $message = Array.from(message); for (let i = 0; i < urls.length; ++i) { const $url = urls[i]; if (!isURLData($url)) { break; } const { url, expanded_url: expandedUrl, indices: [start, end] } = $url; const alreadyExpanded = !isTrackedUrl(url) && expandedUrl === url; if (!alreadyExpanded) { continue; } const trackedUrl = context.lang === "zxx" ? message : $message.slice(start, end).join(""); seen.set(trackedUrl, expandedUrl); } return message; } /* * reduce the keys under $.legacy (typically around 30) to the * handful we care about */ onLegacyObject(value) { const filtered = {}; for (let i = 0; i < LEGACY_KEYS.length; ++i) { const key = LEGACY_KEYS[i]; if (key in value) { filtered[key] = value[key]; } } return filtered; } /* * expand t.co URL nodes in place, either $.url or $.string_value in * binding_values arrays/objects */ onTrackedURL(context, key, url) { const { seen, unresolved } = this; let expandedUrl; if (expandedUrl = seen.get(url)) { context[key] = expandedUrl; ++this.count; } else if (expandedUrl = checkUrl(context.expanded_url || context.expanded)) { seen.set(url, expandedUrl); context[key] = expandedUrl; ++this.count; } else { let targets = unresolved.get(url); if (!targets) { unresolved.set(url, targets = []); } targets.push({ target: context, key }); } return url; } /* * traverse an object by hijacking JSON.stringify's visitor (replacer). * dispatches each node to the +visit+ method */ traverse(data) { if (!isObject(data)) { return; } const self = this; const replacer = function(key, value) { return Array.isArray(this) ? value : self.visit(this, key, value); }; JSON.stringify(data, replacer); } /* * visitor callback which replaces a t.co +url+ property in an object with * its expanded URL */ visit(context, key, value) { if (PRUNE_KEYS.has(key)) { return 0; } switch (key) { case "binding_values": return this.onBindingValues(value); case "full_text": if (isString(value)) { return this.onFullText(context, value); } break; case "legacy": if (isPlainObject(value)) { return this.onLegacyObject(value); } break; case "string_value": case "url": if (isTrackedUrl(value)) { return this.onTrackedURL(context, key, value); } break; } return value; } }; var replacer_default = Replacer; // src/twitter-direct.user.ts // @license GPL var URL_BLACKLIST = /* @__PURE__ */ new Set([ "/hashflags.json", "/badge_count/badge_count.json", "/graphql/articleNudgeDomains", "/graphql/TopicToFollowSidebar" ]); var CONTENT_TYPE = /^application\/json\b/; var LOG_THRESHOLD = ####; var STATS = {}; var TWITTER_API = /^(?:(?:api|mobile)\.)?(?:twitter|x)\.com$/; var onResponse = (xhr, uri) => { const contentType = xhr.getResponseHeader("Content-Type"); if (!contentType || !CONTENT_TYPE.test(contentType)) { return; } const url = new URL(uri); if (!TWITTER_API.test(url.hostname)) { return; } const json = xhr.responseText; const size = json.length; const path = url.pathname.replace(/^\/i\/api\//, "/").replace(/^\/\d+(\.\d+)*\//, "/").replace(/(\/graphql\/)[^\/]+\/(.+)$/, "$1$2").replace(/\/\d+\.json$/, ".json"); if (URL_BLACKLIST.has(path)) { return; } let data; try { data = JSON.parse(json); } catch (e) { console.error(`Can't parse JSON for ${uri}:`, e); return; } if (!isObject(data)) { return; } const newPath = !(path in STATS); const count = replacer_default.transform(data, path); STATS[path] = (STATS[path] || 0) + count; if (!count) { if (!STATS[path] && size > LOG_THRESHOLD) { console.debug(`no replacements in ${path} (${size} B)`); } return; } const descriptor = { value: JSON.stringify(data) }; const clone = GMCompat.export(descriptor); GMCompat.unsafeWindow.Object.defineProperty(xhr, "responseText", clone); const replacements = "replacement" + (count === 1 ? "" : "s"); console.debug(`${count} ${replacements} in ${path} (${size} B)`); if (newPath) { console.log(STATS); } }; var hookXHRSend = (oldSend) => { return function send2(body = null) { const oldOnReadyStateChange = this.onreadystatechange; this.onreadystatechange = function(event) { if (this.readyState === this.DONE && this.responseURL && this.status === 200) { onResponse(this, this.responseURL); } if (oldOnReadyStateChange) { oldOnReadyStateChange.call(this, event); } }; oldSend.call(this, body); }; }; var xhrProto = GMCompat.unsafeWindow.XMLHttpRequest.prototype; var send = hookXHRSend(xhrProto.send); xhrProto.send = GMCompat.export(send); })();