Twitter Direct

Remove t.co tracking links from Twitter

当前为 2023-04-08 提交的版本,查看 最新版本

  1. // ==UserScript==
  2. // @name Twitter Direct
  3. // @description Remove t.co tracking links from Twitter
  4. // @author chocolateboy
  5. // @copyright chocolateboy
  6. // @version 2.3.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 checkUrl = function() {
  23. const urlPattern = /^https?:\/\/\w/i;
  24. return (value) => urlPattern.test(value) && value;
  25. }();
  26. var isObject = (value) => !!value && typeof value === "object";
  27. var isPlainObject = function() {
  28. const toString = {}.toString;
  29. return (value) => toString.call(value) === "[object Object]";
  30. }();
  31. var typeOf = (value) => value === null ? "null" : typeof value;
  32. var isType = (type) => {
  33. return (value) => {
  34. return typeOf(value) === type;
  35. };
  36. };
  37. var isString = isType("string");
  38. var isNumber = isType("number");
  39. var isTrackedUrl = function() {
  40. const urlPattern = /^https?:\/\/t\.co\/\w+$/;
  41. return (value) => urlPattern.test(value);
  42. }();
  43.  
  44. // src/twitter-direct/transformer.ts
  45. var CONTENT_TYPE = /^application\/json\b/;
  46. var DOCUMENT_ROOTS = [
  47. "data",
  48. "globalObjects",
  49. "inbox_initial_state",
  50. "modules",
  51. // TweetDeck
  52. "users"
  53. ];
  54. var LEGACY_KEYS = [
  55. "binding_values",
  56. "entities",
  57. "extended_entities",
  58. "quoted_status_permalink",
  59. "retweeted_status",
  60. "retweeted_status_result",
  61. "user_refs"
  62. ];
  63. var LOG_THRESHOLD = 1024;
  64. var PRUNE_KEYS = /* @__PURE__ */ new Set([
  65. "advertiser_account_service_levels",
  66. "card_platform",
  67. "clientEventInfo",
  68. "ext",
  69. "ext_media_color",
  70. "features",
  71. "feedbackInfo",
  72. "hashtags",
  73. "indices",
  74. "original_info",
  75. "player_image_color",
  76. "profile_banner_extensions",
  77. "profile_banner_extensions_media_color",
  78. "profile_image_extensions",
  79. "profile_image_extensions_media_color",
  80. "responseObjects",
  81. "sizes",
  82. "user_mentions",
  83. "video_info"
  84. ]);
  85. var STATS = {};
  86. var TWITTER_API = /^(?:(?:api|mobile)\.)?twitter\.com$/;
  87. var isSummary = (value) => {
  88. return isPlainObject(value) && isString(value.text) && Array.isArray(value.entities);
  89. };
  90. var isEntity = (value) => {
  91. return isPlainObject(value) && isNumber(value.fromIndex) && isNumber(value.toIndex) && isPlainObject(value.ref) && isString(value.ref.url);
  92. };
  93. var Transformer = class {
  94. urlBlacklist;
  95. /*
  96. * replace the default XHR#send with our custom version, which scans responses
  97. * for tweets and expands their URLs
  98. */
  99. static register(options) {
  100. const transformer = new this(options);
  101. const xhrProto = GMCompat.unsafeWindow.XMLHttpRequest.prototype;
  102. const send = transformer.hookXHRSend(xhrProto.send);
  103. xhrProto.send = GMCompat.export(send);
  104. return transformer;
  105. }
  106. constructor(options) {
  107. this.urlBlacklist = options.urlBlacklist || /* @__PURE__ */ new Set();
  108. }
  109. /*
  110. * replacement for Twitter's default handler for XHR requests. we transform the
  111. * response if it's a) JSON and b) contains URL data; otherwise, we leave it
  112. * unchanged
  113. */
  114. onResponse(xhr, uri) {
  115. const contentType = xhr.getResponseHeader("Content-Type");
  116. if (!contentType || !CONTENT_TYPE.test(contentType)) {
  117. return;
  118. }
  119. const url = new URL(uri);
  120. if (!TWITTER_API.test(url.hostname)) {
  121. return;
  122. }
  123. const json = xhr.responseText;
  124. const size = json.length;
  125. const path = url.pathname.replace(/^\/i\/api\//, "/").replace(/^\/\d+(\.\d+)*\//, "/").replace(/(\/graphql\/)[^\/]+\/(.+)$/, "$1$2").replace(/\/\d+\.json$/, ".json");
  126. if (this.urlBlacklist.has(path)) {
  127. return;
  128. }
  129. let data;
  130. try {
  131. data = JSON.parse(json);
  132. } catch (e) {
  133. console.error(`Can't parse JSON for ${uri}:`, e);
  134. return;
  135. }
  136. if (!isObject(data)) {
  137. return;
  138. }
  139. const newPath = !(path in STATS);
  140. const count = this.transform(data, path);
  141. STATS[path] = (STATS[path] || 0) + count;
  142. if (!count) {
  143. if (!STATS[path] && size > LOG_THRESHOLD) {
  144. console.debug(`no replacements in ${path} (${size} B)`);
  145. }
  146. return;
  147. }
  148. const descriptor = { value: JSON.stringify(data) };
  149. const clone = GMCompat.export(descriptor);
  150. GMCompat.unsafeWindow.Object.defineProperty(xhr, "responseText", clone);
  151. const replacements = "replacement" + (count === 1 ? "" : "s");
  152. console.debug(`${count} ${replacements} in ${path} (${size} B)`);
  153. if (newPath) {
  154. console.log(STATS);
  155. }
  156. }
  157. /*
  158. * replace t.co URLs with the original URL in all locations in the document
  159. * which may contain them
  160. *
  161. * returns the number of substituted URLs
  162. */
  163. transform(data, path) {
  164. const seen = /* @__PURE__ */ new Map();
  165. const unresolved = /* @__PURE__ */ new Map();
  166. const state = { path, count: 0, seen, unresolved };
  167. if (Array.isArray(data) || "id_str" in data) {
  168. this.traverse(state, data);
  169. } else {
  170. for (const key of DOCUMENT_ROOTS) {
  171. if (key in data) {
  172. this.traverse(state, data[key]);
  173. }
  174. }
  175. }
  176. for (const [url, targets] of unresolved) {
  177. const expandedUrl = seen.get(url);
  178. if (expandedUrl) {
  179. for (const { target, key } of targets) {
  180. target[key] = expandedUrl;
  181. ++state.count;
  182. }
  183. unresolved.delete(url);
  184. }
  185. }
  186. if (unresolved.size) {
  187. console.warn(`unresolved URIs (${path}):`, Object.fromEntries(state.unresolved));
  188. }
  189. return state.count;
  190. }
  191. /*
  192. * reduce the large binding_values array/object to the one property we care
  193. * about (card_url)
  194. */
  195. transformBindingValues(value) {
  196. if (Array.isArray(value)) {
  197. const found = value.find((it) => it?.key === "card_url");
  198. return found ? [found] : 0;
  199. } else if (isPlainObject(value)) {
  200. return { card_url: value.card_url || 0 };
  201. } else {
  202. return 0;
  203. }
  204. }
  205. /*
  206. * reduce the keys under context.legacy (typically around 30) to the
  207. * handful we care about
  208. */
  209. transformLegacyObject(value) {
  210. const filtered = {};
  211. for (let i = 0; i < LEGACY_KEYS.length; ++i) {
  212. const key = LEGACY_KEYS[i];
  213. if (key in value) {
  214. filtered[key] = value[key];
  215. }
  216. }
  217. return filtered;
  218. }
  219. /*
  220. * extract expanded URLs from a summary object
  221. *
  222. * the expanded URLs are only extracted here; they're substituted when the
  223. * +url+ property within the summary is visited
  224. */
  225. transformSummary(state, summary) {
  226. const { entities, text } = summary;
  227. for (const entity of entities) {
  228. if (!isEntity(entity)) {
  229. console.warn("invalid entity:", entity);
  230. break;
  231. }
  232. const { url } = entity.ref;
  233. if (isTrackedUrl(url)) {
  234. const expandedUrl = text.slice(entity.fromIndex, entity.toIndex);
  235. state.seen.set(url, expandedUrl);
  236. }
  237. }
  238. return summary;
  239. }
  240. /*
  241. * expand t.co URL nodes in place, either obj.url or obj.string_value in
  242. * binding_values arrays/objects
  243. */
  244. transformURL(state, context, key, url) {
  245. const { seen, unresolved } = state;
  246. const writable = this.isWritable(context);
  247. let expandedUrl;
  248. if (expandedUrl = seen.get(url)) {
  249. if (writable) {
  250. context[key] = expandedUrl;
  251. ++state.count;
  252. }
  253. } else if (expandedUrl = checkUrl(context.expanded_url || context.expanded)) {
  254. seen.set(url, expandedUrl);
  255. if (writable) {
  256. context[key] = expandedUrl;
  257. ++state.count;
  258. }
  259. } else {
  260. let targets = unresolved.get(url);
  261. if (!targets) {
  262. unresolved.set(url, targets = []);
  263. }
  264. if (writable) {
  265. targets.push({ target: context, key });
  266. }
  267. }
  268. return url;
  269. }
  270. /*
  271. * replace the built-in XHR#send method with a custom version which swaps
  272. * in our custom response handler. once done, we delegate to the original
  273. * handler (this.onreadystatechange)
  274. */
  275. hookXHRSend(oldSend) {
  276. const self = this;
  277. return function send(body = null) {
  278. const oldOnReadyStateChange = this.onreadystatechange;
  279. this.onreadystatechange = function(event) {
  280. if (this.readyState === this.DONE && this.responseURL && this.status === 200) {
  281. self.onResponse(this, this.responseURL);
  282. }
  283. if (oldOnReadyStateChange) {
  284. oldOnReadyStateChange.call(this, event);
  285. }
  286. };
  287. oldSend.call(this, body);
  288. };
  289. }
  290. /*
  291. * a hook which a subclass can use to veto an expansion.
  292. *
  293. * used by TweetDeck Direct to preserve t.co URLs which are expanded in the
  294. * UI (via a data-full-url attribute on the link)
  295. */
  296. isWritable(_context) {
  297. return true;
  298. }
  299. /*
  300. * traverse an object by hijacking JSON.stringify's visitor (replacer).
  301. * dispatches each node to the +visit+ method
  302. */
  303. traverse(state, data) {
  304. if (!isObject(data)) {
  305. return;
  306. }
  307. const self = this;
  308. const replacer = function(key, value) {
  309. return Array.isArray(this) ? value : self.visit(state, this, key, value);
  310. };
  311. JSON.stringify(data, replacer);
  312. }
  313. /*
  314. * visitor callback which replaces a t.co +url+ property in an object with
  315. * its expanded version
  316. */
  317. visit(state, context, key, value) {
  318. if (PRUNE_KEYS.has(key)) {
  319. return 0;
  320. }
  321. switch (key) {
  322. case "binding_values":
  323. return this.transformBindingValues(value);
  324. case "legacy":
  325. if (isPlainObject(value)) {
  326. return this.transformLegacyObject(value);
  327. }
  328. break;
  329. case "string_value":
  330. case "url":
  331. if (isTrackedUrl(value)) {
  332. return this.transformURL(state, context, key, value);
  333. }
  334. break;
  335. case "summary":
  336. if (isSummary(value)) {
  337. return this.transformSummary(state, value);
  338. }
  339. }
  340. return value;
  341. }
  342. };
  343.  
  344. // src/twitter-direct.user.ts
  345. // @license GPL
  346. var URL_BLACKLIST = /* @__PURE__ */ new Set([
  347. "/hashflags.json",
  348. "/badge_count/badge_count.json",
  349. "/graphql/articleNudgeDomains",
  350. "/graphql/TopicToFollowSidebar"
  351. ]);
  352. Transformer.register({ urlBlacklist: URL_BLACKLIST });
  353. })();