Twitter Direct

Remove t.co tracking links from Twitter

当前为 2024-05-05 提交的版本,查看 最新版本

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