Twitter Direct

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/gm-compat@1.1.0/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] && 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);
})();