Twitter Direct

Remove t.co tracking links from Twitter

目前為 2022-12-13 提交的版本,檢視 最新版本

  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.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 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. "users"
  52. ];
  53. var LEGACY_KEYS = [
  54. "binding_values",
  55. "entities",
  56. "extended_entities",
  57. "quoted_status_permalink",
  58. "retweeted_status",
  59. "retweeted_status_result",
  60. "user_refs"
  61. ];
  62. var LOG_THRESHOLD = 1024;
  63. var PRUNE_KEYS = /* @__PURE__ */ new Set([
  64. "advertiser_account_service_levels",
  65. "card_platform",
  66. "clientEventInfo",
  67. "ext",
  68. "ext_media_color",
  69. "features",
  70. "feedbackInfo",
  71. "hashtags",
  72. "indices",
  73. "original_info",
  74. "player_image_color",
  75. "profile_banner_extensions",
  76. "profile_banner_extensions_media_color",
  77. "profile_image_extensions",
  78. "profile_image_extensions_media_color",
  79. "responseObjects",
  80. "sizes",
  81. "user_mentions",
  82. "video_info"
  83. ]);
  84. var STATS = {};
  85. var TWITTER_API = /^(?:(?:api|mobile)\.)?twitter\.com$/;
  86. var isSummary = (value) => {
  87. return isPlainObject(value) && isString(value.text) && Array.isArray(value.entities);
  88. };
  89. var isEntity = (value) => {
  90. return isPlainObject(value) && isNumber(value.fromIndex) && isNumber(value.toIndex) && isPlainObject(value.ref) && isString(value.ref.url);
  91. };
  92. var Transformer = class {
  93. urlBlacklist;
  94. static register(options) {
  95. const transformer = new this(options);
  96. const xhrProto = GMCompat.unsafeWindow.XMLHttpRequest.prototype;
  97. const send = transformer.hookXHRSend(xhrProto.send);
  98. xhrProto.send = GMCompat.export(send);
  99. return transformer;
  100. }
  101. constructor(options) {
  102. this.urlBlacklist = options.urlBlacklist || /* @__PURE__ */ new Set();
  103. }
  104. onResponse(xhr, uri) {
  105. const contentType = xhr.getResponseHeader("Content-Type");
  106. if (!contentType || !CONTENT_TYPE.test(contentType)) {
  107. return;
  108. }
  109. const url = new URL(uri);
  110. if (!TWITTER_API.test(url.hostname)) {
  111. return;
  112. }
  113. const json = xhr.responseText;
  114. const size = json.length;
  115. const path = url.pathname.replace(/^\/i\/api\//, "/").replace(/^\/\d+(\.\d+)*\//, "/").replace(/(\/graphql\/)[^\/]+\/(.+)$/, "$1$2").replace(/\/\d+\.json$/, ".json");
  116. if (this.urlBlacklist.has(path)) {
  117. return;
  118. }
  119. let data;
  120. try {
  121. data = JSON.parse(json);
  122. } catch (e) {
  123. console.error(`Can't parse JSON for ${uri}:`, e);
  124. return;
  125. }
  126. if (!isObject(data)) {
  127. return;
  128. }
  129. const newPath = !(path in STATS);
  130. const count = this.transform(data, path);
  131. STATS[path] = (STATS[path] || 0) + count;
  132. if (!count) {
  133. if (!STATS[path] && size > LOG_THRESHOLD) {
  134. console.debug(`no replacements in ${path} (${size} B)`);
  135. }
  136. return;
  137. }
  138. const descriptor = { value: JSON.stringify(data) };
  139. const clone = GMCompat.export(descriptor);
  140. GMCompat.unsafeWindow.Object.defineProperty(xhr, "responseText", clone);
  141. const replacements = "replacement" + (count === 1 ? "" : "s");
  142. console.debug(`${count} ${replacements} in ${path} (${size} B)`);
  143. if (newPath) {
  144. console.log(STATS);
  145. }
  146. }
  147. transform(data, path) {
  148. const seen = /* @__PURE__ */ new Map();
  149. const unresolved = /* @__PURE__ */ new Map();
  150. const state = { path, count: 0, seen, unresolved };
  151. if (Array.isArray(data) || "id_str" in data) {
  152. this.traverse(state, data);
  153. } else {
  154. for (const key of DOCUMENT_ROOTS) {
  155. if (key in data) {
  156. this.traverse(state, data[key]);
  157. }
  158. }
  159. }
  160. for (const [url, targets] of unresolved) {
  161. const expandedUrl = seen.get(url);
  162. if (expandedUrl) {
  163. for (const { target, key } of targets) {
  164. target[key] = expandedUrl;
  165. ++state.count;
  166. }
  167. unresolved.delete(url);
  168. }
  169. }
  170. if (unresolved.size) {
  171. console.warn(`unresolved URIs (${path}):`, Object.fromEntries(state.unresolved));
  172. }
  173. return state.count;
  174. }
  175. transformBindingValues(value) {
  176. if (Array.isArray(value)) {
  177. const found = value.find((it) => it?.key === "card_url");
  178. return found ? [found] : 0;
  179. } else if (isPlainObject(value)) {
  180. return { card_url: value.card_url || 0 };
  181. } else {
  182. return 0;
  183. }
  184. }
  185. transformLegacyObject(value) {
  186. const filtered = {};
  187. for (let i = 0; i < LEGACY_KEYS.length; ++i) {
  188. const key = LEGACY_KEYS[i];
  189. if (key in value) {
  190. filtered[key] = value[key];
  191. }
  192. }
  193. return filtered;
  194. }
  195. transformSummary(state, summary) {
  196. const { entities, text } = summary;
  197. for (const entity of entities) {
  198. if (!isEntity(entity)) {
  199. console.warn("invalid entity:", entity);
  200. break;
  201. }
  202. const { url } = entity.ref;
  203. if (isTrackedUrl(url)) {
  204. const expandedUrl = text.slice(entity.fromIndex, entity.toIndex);
  205. state.seen.set(url, expandedUrl);
  206. }
  207. }
  208. return summary;
  209. }
  210. transformURL(state, context, key, value) {
  211. const { seen, unresolved } = state;
  212. const writable = this.isWritable(context);
  213. let expandedUrl;
  214. if (expandedUrl = seen.get(value)) {
  215. if (writable) {
  216. context[key] = expandedUrl;
  217. ++state.count;
  218. }
  219. } else if (expandedUrl = checkUrl(context.expanded_url || context.expanded)) {
  220. seen.set(value, expandedUrl);
  221. if (writable) {
  222. context[key] = expandedUrl;
  223. ++state.count;
  224. }
  225. } else {
  226. let targets = unresolved.get(value);
  227. if (!targets) {
  228. unresolved.set(value, targets = []);
  229. }
  230. if (writable) {
  231. targets.push({ target: context, key });
  232. }
  233. }
  234. return value;
  235. }
  236. hookXHRSend(oldSend) {
  237. const self = this;
  238. return function send(body = null) {
  239. const oldOnReadyStateChange = this.onreadystatechange;
  240. this.onreadystatechange = function(event) {
  241. if (this.readyState === this.DONE && this.responseURL && this.status === 200) {
  242. self.onResponse(this, this.responseURL);
  243. }
  244. if (oldOnReadyStateChange) {
  245. oldOnReadyStateChange.call(this, event);
  246. }
  247. };
  248. oldSend.call(this, body);
  249. };
  250. }
  251. isWritable(_context) {
  252. return true;
  253. }
  254. traverse(state, data) {
  255. if (!isObject(data)) {
  256. return;
  257. }
  258. const self = this;
  259. const replacer = function(key, value) {
  260. return Array.isArray(this) ? value : self.visit(state, this, key, value);
  261. };
  262. JSON.stringify(data, replacer);
  263. }
  264. visit(state, context, key, value) {
  265. if (PRUNE_KEYS.has(key)) {
  266. return 0;
  267. }
  268. switch (key) {
  269. case "binding_values":
  270. return this.transformBindingValues(value);
  271. case "legacy":
  272. if (isPlainObject(value)) {
  273. return this.transformLegacyObject(value);
  274. }
  275. break;
  276. case "string_value":
  277. case "url":
  278. if (isTrackedUrl(value)) {
  279. return this.transformURL(state, context, key, value);
  280. }
  281. break;
  282. case "summary":
  283. if (isSummary(value)) {
  284. return this.transformSummary(state, value);
  285. }
  286. }
  287. return value;
  288. }
  289. };
  290.  
  291. // src/twitter-direct.user.ts
  292. // @license GPL
  293. var URL_BLACKLIST = /* @__PURE__ */ new Set([
  294. "/hashflags.json",
  295. "/badge_count/badge_count.json",
  296. "/graphql/articleNudgeDomains",
  297. "/graphql/TopicToFollowSidebar"
  298. ]);
  299. Transformer.register({ urlBlacklist: URL_BLACKLIST });
  300. })();