Twitter Direct

Remove t.co tracking links from Twitter

Na nainštalovanie skriptu si budete musieť nainštalovať rozšírenie, ako napríklad Tampermonkey, Greasemonkey alebo Violentmonkey.

Na nainštalovanie skriptu si budete musieť nainštalovať rozšírenie, ako napríklad Tampermonkey, % alebo Violentmonkey.

Na nainštalovanie skriptu si budete musieť nainštalovať rozšírenie, ako napríklad Tampermonkey, % alebo Violentmonkey.

Na nainštalovanie skriptu si budete musieť nainštalovať rozšírenie, ako napríklad Tampermonkey alebo Userscripts.

Na inštaláciu tohto skriptu je potrebné nainštalovať rozšírenie, ako napríklad Tampermonkey.

Na inštaláciu tohto skriptu je potrebné nainštalovať rozšírenie správcu používateľských skriptov.

(Už mám správcu používateľských skriptov, nechajte ma ho nainštalovať!)

Na inštaláciu tohto štýlu je potrebné nainštalovať rozšírenie, ako napríklad Stylus.

Na inštaláciu tohto štýlu je potrebné nainštalovať rozšírenie, ako napríklad Stylus.

Na inštaláciu tohto štýlu je potrebné nainštalovať rozšírenie, ako napríklad Stylus.

Na inštaláciu tohto štýlu je potrebné nainštalovať rozšírenie správcu používateľských štýlov.

Na inštaláciu tohto štýlu je potrebné nainštalovať rozšírenie správcu používateľských štýlov.

Na inštaláciu tohto štýlu je potrebné nainštalovať rozšírenie správcu používateľských štýlov.

(Už mám správcu používateľských štýlov, nechajte ma ho nainštalovať!)

// ==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);
})();