Twitter Direct

Remove t.co tracking links from Twitter

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

  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.1
  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. const $message = Array.from(message);
  168. for (let i = 0; i < urls.length; ++i) {
  169. const $url = urls[i];
  170. if (!isURLData($url)) {
  171. break;
  172. }
  173. const {
  174. url,
  175. expanded_url: expandedUrl,
  176. indices: [start, end]
  177. } = $url;
  178. const alreadyExpanded = !isTrackedUrl(url) && expandedUrl === url;
  179. if (!alreadyExpanded) {
  180. continue;
  181. }
  182. const trackedUrl = context.lang === "zxx" ? message : $message.slice(start, end).join("");
  183. seen.set(trackedUrl, expandedUrl);
  184. }
  185. return message;
  186. }
  187. /*
  188. * reduce the keys under context.legacy (typically around 30) to the
  189. * handful we care about
  190. */
  191. onLegacyObject(value) {
  192. const filtered = {};
  193. for (let i = 0; i < LEGACY_KEYS.length; ++i) {
  194. const key = LEGACY_KEYS[i];
  195. if (key in value) {
  196. filtered[key] = value[key];
  197. }
  198. }
  199. return filtered;
  200. }
  201. /*
  202. * expand t.co URL nodes in place, either $.url or $.string_value in
  203. * binding_values arrays/objects
  204. */
  205. onTrackedURL(context, key, url) {
  206. const { seen, unresolved } = this;
  207. let expandedUrl;
  208. if (expandedUrl = seen.get(url)) {
  209. context[key] = expandedUrl;
  210. ++this.count;
  211. } else if (expandedUrl = checkUrl(context.expanded_url || context.expanded)) {
  212. seen.set(url, expandedUrl);
  213. context[key] = expandedUrl;
  214. ++this.count;
  215. } else {
  216. let targets = unresolved.get(url);
  217. if (!targets) {
  218. unresolved.set(url, targets = []);
  219. }
  220. targets.push({ target: context, key });
  221. }
  222. return url;
  223. }
  224. /*
  225. * traverse an object by hijacking JSON.stringify's visitor (replacer).
  226. * dispatches each node to the +visit+ function
  227. */
  228. traverse(data) {
  229. if (!isObject(data)) {
  230. return;
  231. }
  232. const self = this;
  233. const replacer = function(key, value) {
  234. return Array.isArray(this) ? value : self.visit(this, key, value);
  235. };
  236. JSON.stringify(data, replacer);
  237. }
  238. /*
  239. * visitor callback which replaces a t.co +url+ property in an object with
  240. * its expanded URL
  241. */
  242. visit(context, key, value) {
  243. if (PRUNE_KEYS.has(key)) {
  244. return 0;
  245. }
  246. switch (key) {
  247. case "binding_values":
  248. return this.onBindingValues(value);
  249. case "full_text":
  250. if (isString(value)) {
  251. return this.onFullText(context, value);
  252. }
  253. break;
  254. case "legacy":
  255. if (isPlainObject(value)) {
  256. return this.onLegacyObject(value);
  257. }
  258. break;
  259. case "string_value":
  260. case "url":
  261. if (isTrackedUrl(value)) {
  262. return this.onTrackedURL(context, key, value);
  263. }
  264. break;
  265. }
  266. return value;
  267. }
  268. };
  269. var replacer_default = Replacer;
  270.  
  271. // src/twitter-direct.user.ts
  272. // @license GPL
  273. var URL_BLACKLIST = /* @__PURE__ */ new Set([
  274. "/hashflags.json",
  275. "/badge_count/badge_count.json",
  276. "/graphql/articleNudgeDomains",
  277. "/graphql/TopicToFollowSidebar"
  278. ]);
  279. var CONTENT_TYPE = /^application\/json\b/;
  280. var LOG_THRESHOLD = 1024;
  281. var STATS = {};
  282. var TWITTER_API = /^(?:(?:api|mobile)\.)?twitter\.com$/;
  283. var onResponse = (xhr, uri) => {
  284. const contentType = xhr.getResponseHeader("Content-Type");
  285. if (!contentType || !CONTENT_TYPE.test(contentType)) {
  286. return;
  287. }
  288. const url = new URL(uri);
  289. if (!TWITTER_API.test(url.hostname)) {
  290. return;
  291. }
  292. const json = xhr.responseText;
  293. const size = json.length;
  294. const path = url.pathname.replace(/^\/i\/api\//, "/").replace(/^\/\d+(\.\d+)*\//, "/").replace(/(\/graphql\/)[^\/]+\/(.+)$/, "$1$2").replace(/\/\d+\.json$/, ".json");
  295. if (URL_BLACKLIST.has(path)) {
  296. return;
  297. }
  298. let data;
  299. try {
  300. data = JSON.parse(json);
  301. } catch (e) {
  302. console.error(`Can't parse JSON for ${uri}:`, e);
  303. return;
  304. }
  305. if (!isObject(data)) {
  306. return;
  307. }
  308. const newPath = !(path in STATS);
  309. const count = replacer_default.transform(data, path);
  310. STATS[path] = (STATS[path] || 0) + count;
  311. if (!count) {
  312. if (!STATS[path] && size > LOG_THRESHOLD) {
  313. console.debug(`no replacements in ${path} (${size} B)`);
  314. }
  315. return;
  316. }
  317. const descriptor = { value: JSON.stringify(data) };
  318. const clone = GMCompat.export(descriptor);
  319. GMCompat.unsafeWindow.Object.defineProperty(xhr, "responseText", clone);
  320. const replacements = "replacement" + (count === 1 ? "" : "s");
  321. console.debug(`${count} ${replacements} in ${path} (${size} B)`);
  322. if (newPath) {
  323. console.log(STATS);
  324. }
  325. };
  326. var hookXHRSend = (oldSend) => {
  327. return function send2(body = null) {
  328. const oldOnReadyStateChange = this.onreadystatechange;
  329. this.onreadystatechange = function(event) {
  330. if (this.readyState === this.DONE && this.responseURL && this.status === 200) {
  331. onResponse(this, this.responseURL);
  332. }
  333. if (oldOnReadyStateChange) {
  334. oldOnReadyStateChange.call(this, event);
  335. }
  336. };
  337. oldSend.call(this, body);
  338. };
  339. };
  340. var xhrProto = GMCompat.unsafeWindow.XMLHttpRequest.prototype;
  341. var send = hookXHRSend(xhrProto.send);
  342. xhrProto.send = GMCompat.export(send);
  343. })();

QingJ © 2025

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