Twitter Direct

Remove t.co tracking links from Twitter

You will need to install an extension such as Tampermonkey, Greasemonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey or Userscripts to install this script.

You will need to install an extension such as Tampermonkey to install this script.

You will need to install a user script manager extension to install this script.

(I already have a user script manager, let me install it!)

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

(I already have a user style manager, let me install it!)

// ==UserScript==
// @name          Twitter Direct
// @description   Remove t.co tracking links from Twitter
// @author        chocolateboy
// @copyright     chocolateboy
// @version       3.1.2
// @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_result",
    "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 = 1024;
  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] === 0 && 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);
})();