Twitter Direct

Remove t.co tracking links from Twitter

  1. // ==UserScript==
  2. // @name Twitter Direct
  3. // @description Remove t.co tracking links from Twitter
  4. // @author chocolateboy
  5. // @copyright chocolateboy
  6. // @version 3.1.2
  7. // @namespace https://github.com/chocolateboy/userscripts
  8. // @license GPL
  9. // @include https://mobile.twitter.com/
  10. // @include https://mobile.twitter.com/*
  11. // @include https://twitter.com/
  12. // @include https://twitter.com/*
  13. // @include https://x.com/
  14. // @include https://x.com/*
  15. // @require https://unpkg.com/gm-compat@1.1.0/dist/index.iife.min.js
  16. // @run-at document-start
  17. // ==/UserScript==
  18.  
  19. // NOTE This file is generated from src/twitter-direct.user.ts and should not be edited directly.
  20.  
  21. "use strict";
  22. (() => {
  23. // src/twitter-direct/util.ts
  24. var isObject = (value) => !!value && typeof value === "object";
  25. var isPlainObject = function() {
  26. const toString = {}.toString;
  27. return (value) => toString.call(value) === "[object Object]";
  28. }();
  29. var typeOf = (value) => value === null ? "null" : typeof value;
  30. var isType = (type) => {
  31. return (value) => {
  32. return typeOf(value) === type;
  33. };
  34. };
  35. var isString = isType("string");
  36. var isNumber = isType("number");
  37.  
  38. // src/twitter-direct/replacer.ts
  39. var DOCUMENT_ROOTS = [
  40. "data",
  41. "globalObjects",
  42. "inbox_initial_state",
  43. "users"
  44. ];
  45. var LEGACY_KEYS = [
  46. "binding_values",
  47. "entities",
  48. "extended_entities",
  49. "full_text",
  50. "lang",
  51. "quoted_status_permalink",
  52. "retweeted_status",
  53. "retweeted_status_result",
  54. "user_refs"
  55. ];
  56. var PRUNE_KEYS = /* @__PURE__ */ new Set([
  57. "advertiser_account_service_levels",
  58. "card_platform",
  59. "clientEventInfo",
  60. "ext",
  61. "ext_media_color",
  62. "features",
  63. "feedbackInfo",
  64. "hashtags",
  65. "indices",
  66. "original_info",
  67. "player_image_color",
  68. "profile_banner_extensions",
  69. "profile_banner_extensions_media_color",
  70. "profile_image_extensions",
  71. "profile_image_extensions_media_color",
  72. "responseObjects",
  73. "sizes",
  74. "user_mentions",
  75. "video_info"
  76. ]);
  77. var checkUrl = /* @__PURE__ */ function() {
  78. const urlPattern = /^https?:\/\/\w/i;
  79. return (value) => urlPattern.test(value) && value;
  80. }();
  81. var isTrackedUrl = /* @__PURE__ */ function() {
  82. const urlPattern = /^https?:\/\/t\.co\/\w+$/;
  83. return (value) => urlPattern.test(value);
  84. }();
  85. var isURLData = (value) => {
  86. return isPlainObject(value) && isString(value.url) && isString(value.expanded_url) && Array.isArray(value.indices) && isNumber(value.indices[0]) && isNumber(value.indices[1]);
  87. };
  88. var Replacer = class _Replacer {
  89. seen = /* @__PURE__ */ new Map();
  90. unresolved = /* @__PURE__ */ new Map();
  91. count = 0;
  92. static transform(data, path) {
  93. const replacer = new _Replacer();
  94. return replacer.transform(data, path);
  95. }
  96. /*
  97. * replace t.co URLs with the original URL in all locations in the document
  98. * which may contain them
  99. *
  100. * returns the number of substituted URLs
  101. */
  102. transform(data, path) {
  103. const { seen, unresolved } = this;
  104. if (Array.isArray(data) || "id_str" in data) {
  105. this.traverse(data);
  106. } else {
  107. for (const key of DOCUMENT_ROOTS) {
  108. if (key in data) {
  109. this.traverse(data[key]);
  110. }
  111. }
  112. }
  113. for (const [url, targets] of unresolved) {
  114. const expandedUrl = seen.get(url);
  115. if (expandedUrl) {
  116. for (const { target, key } of targets) {
  117. target[key] = expandedUrl;
  118. ++this.count;
  119. }
  120. unresolved.delete(url);
  121. }
  122. }
  123. if (unresolved.size) {
  124. console.warn(`unresolved URIs (${path}):`, Object.fromEntries(unresolved));
  125. }
  126. return this.count;
  127. }
  128. /*
  129. * reduce the large binding_values array/object to the one property we care
  130. * about (card_url)
  131. */
  132. onBindingValues(value) {
  133. if (Array.isArray(value)) {
  134. const found = value.find((it) => it?.key === "card_url");
  135. return found ? [found] : 0;
  136. } else if (isPlainObject(value) && isPlainObject(value.card_url)) {
  137. return [value.card_url];
  138. } else {
  139. return 0;
  140. }
  141. }
  142. /*
  143. * handle cases where the t.co URL is already expanded, e.g.:
  144. *
  145. * {
  146. * "entities": {
  147. * "urls": [
  148. * {
  149. * "display_url": "example.com",
  150. * "expanded_url": "https://www.example.com",
  151. * "url": "https://www.example.com",
  152. * "indices": [16, 39]
  153. * }
  154. * ]
  155. * },
  156. * "full_text": "I'm on the bus! https://t.co/abcde12345"
  157. * }
  158. *
  159. * extract the corresponding t.co URLs from the text via the entities.urls
  160. * records and register the t.co -> expanded URL mappings so they can be
  161. * used later, e.g. https://t.co/abcde12345 -> https://www.example.com
  162. */
  163. onFullText(context, message) {
  164. const seen = this.seen;
  165. const urls = context.entities?.urls;
  166. if (!(Array.isArray(urls) && urls.length)) {
  167. return message;
  168. }
  169. const $message = Array.from(message);
  170. for (let i = 0; i < urls.length; ++i) {
  171. const $url = urls[i];
  172. if (!isURLData($url)) {
  173. break;
  174. }
  175. const {
  176. url,
  177. expanded_url: expandedUrl,
  178. indices: [start, end]
  179. } = $url;
  180. const alreadyExpanded = !isTrackedUrl(url) && expandedUrl === url;
  181. if (!alreadyExpanded) {
  182. continue;
  183. }
  184. const trackedUrl = context.lang === "zxx" ? message : $message.slice(start, end).join("");
  185. seen.set(trackedUrl, expandedUrl);
  186. }
  187. return message;
  188. }
  189. /*
  190. * reduce the keys under $.legacy (typically around 30) to the
  191. * handful we care about
  192. */
  193. onLegacyObject(value) {
  194. const filtered = {};
  195. for (let i = 0; i < LEGACY_KEYS.length; ++i) {
  196. const key = LEGACY_KEYS[i];
  197. if (key in value) {
  198. filtered[key] = value[key];
  199. }
  200. }
  201. return filtered;
  202. }
  203. /*
  204. * expand t.co URL nodes in place, either $.url or $.string_value in
  205. * binding_values arrays/objects
  206. */
  207. onTrackedURL(context, key, url) {
  208. const { seen, unresolved } = this;
  209. let expandedUrl;
  210. if (expandedUrl = seen.get(url)) {
  211. context[key] = expandedUrl;
  212. ++this.count;
  213. } else if (expandedUrl = checkUrl(context.expanded_url || context.expanded)) {
  214. seen.set(url, expandedUrl);
  215. context[key] = expandedUrl;
  216. ++this.count;
  217. } else {
  218. let targets = unresolved.get(url);
  219. if (!targets) {
  220. unresolved.set(url, targets = []);
  221. }
  222. targets.push({ target: context, key });
  223. }
  224. return url;
  225. }
  226. /*
  227. * traverse an object by hijacking JSON.stringify's visitor (replacer).
  228. * dispatches each node to the +visit+ method
  229. */
  230. traverse(data) {
  231. if (!isObject(data)) {
  232. return;
  233. }
  234. const self = this;
  235. const replacer = function(key, value) {
  236. return Array.isArray(this) ? value : self.visit(this, key, value);
  237. };
  238. JSON.stringify(data, replacer);
  239. }
  240. /*
  241. * visitor callback which replaces a t.co +url+ property in an object with
  242. * its expanded URL
  243. */
  244. visit(context, key, value) {
  245. if (PRUNE_KEYS.has(key)) {
  246. return 0;
  247. }
  248. switch (key) {
  249. case "binding_values":
  250. return this.onBindingValues(value);
  251. case "full_text":
  252. if (isString(value)) {
  253. return this.onFullText(context, value);
  254. }
  255. break;
  256. case "legacy":
  257. if (isPlainObject(value)) {
  258. return this.onLegacyObject(value);
  259. }
  260. break;
  261. case "string_value":
  262. case "url":
  263. if (isTrackedUrl(value)) {
  264. return this.onTrackedURL(context, key, value);
  265. }
  266. break;
  267. }
  268. return value;
  269. }
  270. };
  271. var replacer_default = Replacer;
  272.  
  273. // src/twitter-direct.user.ts
  274. // @license GPL
  275. var URL_BLACKLIST = /* @__PURE__ */ new Set([
  276. "/hashflags.json",
  277. "/badge_count/badge_count.json",
  278. "/graphql/articleNudgeDomains",
  279. "/graphql/TopicToFollowSidebar"
  280. ]);
  281. var CONTENT_TYPE = /^application\/json\b/;
  282. var LOG_THRESHOLD = 1024;
  283. var STATS = {};
  284. var TWITTER_API = /^(?:(?:api|mobile)\.)?(?:twitter|x)\.com$/;
  285. var onResponse = (xhr, uri) => {
  286. const contentType = xhr.getResponseHeader("Content-Type");
  287. if (!contentType || !CONTENT_TYPE.test(contentType)) {
  288. return;
  289. }
  290. const url = new URL(uri);
  291. if (!TWITTER_API.test(url.hostname)) {
  292. return;
  293. }
  294. const json = xhr.responseText;
  295. const size = json.length;
  296. const path = url.pathname.replace(/^\/i\/api\//, "/").replace(/^\/\d+(\.\d+)*\//, "/").replace(/(\/graphql\/)[^\/]+\/(.+)$/, "$1$2").replace(/\/\d+\.json$/, ".json");
  297. if (URL_BLACKLIST.has(path)) {
  298. return;
  299. }
  300. let data;
  301. try {
  302. data = JSON.parse(json);
  303. } catch (e) {
  304. console.error(`Can't parse JSON for ${uri}:`, e);
  305. return;
  306. }
  307. if (!isObject(data)) {
  308. return;
  309. }
  310. const newPath = !(path in STATS);
  311. const count = replacer_default.transform(data, path);
  312. STATS[path] = (STATS[path] || 0) + count;
  313. if (!count) {
  314. if (STATS[path] === 0 && size > LOG_THRESHOLD) {
  315. console.debug(`no replacements in ${path} (${size} B)`);
  316. }
  317. return;
  318. }
  319. const descriptor = { value: JSON.stringify(data) };
  320. const clone = GMCompat.export(descriptor);
  321. GMCompat.unsafeWindow.Object.defineProperty(xhr, "responseText", clone);
  322. const replacements = "replacement" + (count === 1 ? "" : "s");
  323. console.debug(`${count} ${replacements} in ${path} (${size} B)`);
  324. if (newPath) {
  325. console.log(STATS);
  326. }
  327. };
  328. var hookXHRSend = (oldSend) => {
  329. return function send2(body = null) {
  330. const oldOnReadyStateChange = this.onreadystatechange;
  331. this.onreadystatechange = function(event) {
  332. if (this.readyState === this.DONE && this.responseURL && this.status === 200) {
  333. onResponse(this, this.responseURL);
  334. }
  335. if (oldOnReadyStateChange) {
  336. oldOnReadyStateChange.call(this, event);
  337. }
  338. };
  339. oldSend.call(this, body);
  340. };
  341. };
  342. var xhrProto = GMCompat.unsafeWindow.XMLHttpRequest.prototype;
  343. var send = hookXHRSend(xhrProto.send);
  344. xhrProto.send = GMCompat.export(send);
  345. })();

QingJ © 2025

镜像随时可能失效,请加Q群300939539或关注我们的公众号极客氢云获取最新地址