Twitter Direct

Remove t.co tracking links from Twitter

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

  1. // ==UserScript==
  2. // @name Twitter Direct
  3. // @description Remove t.co tracking links from Twitter
  4. // @author chocolateboy
  5. // @copyright chocolateboy
  6. // @version 2.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. /*
  18. * a pattern which matches the content-type header of responses we scan for
  19. * URLs: "application/json" or "application/json; charset=utf-8"
  20. */
  21. const CONTENT_TYPE = /^application\/json\b/
  22.  
  23. /*
  24. * document keys under which t.co URL nodes can be found when the document is a
  25. * plain object. not used when the document is an array.
  26. *
  27. * some populous top-level paths don't contain t.co URLs, e.g. $.timeline.
  28. */
  29. const DOCUMENT_ROOTS = [
  30. 'data',
  31. 'globalObjects',
  32. 'inbox_initial_state',
  33. 'users',
  34. ]
  35.  
  36. /*
  37. * the minimum size (in bytes) of documents we deem to be "not small"
  38. *
  39. * we log (to the console) misses (i.e. no URLs ever found/replaced) in
  40. * documents whose size is greater than or equal to this value
  41. */
  42. const LOG_THRESHOLD = 1024
  43.  
  44. /*
  45. * nodes under these keys don't contain t.co URLs so we can speed up traversal
  46. * by pruning (not descending) them
  47. */
  48. const PRUNE_KEYS = new Set([
  49. 'ext_media_color',
  50. 'features',
  51. 'hashtags',
  52. 'original_info',
  53. 'player_image_color',
  54. 'profile_banner_extensions',
  55. 'profile_banner_extensions_media_color',
  56. 'profile_image_extensions',
  57. 'sizes',
  58. ])
  59.  
  60. /*
  61. * a map from URI paths (strings) to the replacement count for each path. used
  62. * to keep a running total of the number of replacements in each document type
  63. */
  64. const STATS = {}
  65.  
  66. /*
  67. * the domain intercepted links are routed through
  68. *
  69. * not all links are intercepted. exceptions include links to twitter (e.g.
  70. * https://twitter.com) and card URIs (e.g. card://123456)
  71. */
  72. const TRACKING_DOMAIN = 't.co'
  73.  
  74. /*
  75. * a pattern which matches the domain(s) we expect data (JSON) to come from.
  76. * responses which don't come from a matching domain are ignored.
  77. */
  78. const TWITTER_API = /^(?:(?:api|mobile)\.)?twitter\.com$/
  79.  
  80. /*
  81. * a list of document URIs (paths) which are known to not contain t.co URLs and
  82. * which therefore don't need to be processed
  83. */
  84. const URL_BLACKLIST = new Set([
  85. '/i/api/2/badge_count/badge_count.json',
  86. '/i/api/graphql/TopicToFollowSidebar',
  87. ])
  88.  
  89. /*
  90. * object keys whose corresponding values may contain t.co URLs
  91. */
  92. const URL_KEYS = new Set(['url', 'string_value'])
  93.  
  94. /*
  95. * return a truthy value (a URL instance) if the supplied value is a valid
  96. * URL (string), falsey otherwise
  97. */
  98. const checkUrl = value => {
  99. let url
  100.  
  101. if (typeof value === 'string') {
  102. try {
  103. url = new URL(value)
  104. } catch {}
  105. }
  106.  
  107. return url
  108. }
  109.  
  110. /*
  111. * replace the built-in XHR#send method with a custom version which swaps in our
  112. * custom response handler. once done, we delegate to the original handler
  113. * (this.onreadystatechange)
  114. */
  115. const hookXHRSend = oldSend => {
  116. return /** @this {XMLHttpRequest} */ function send (body = null) {
  117. const oldOnReadyStateChange = this.onreadystatechange
  118.  
  119. this.onreadystatechange = function (event) {
  120. if (this.readyState === this.DONE && this.responseURL && this.status === 200) {
  121. onResponse(this, this.responseURL)
  122. }
  123.  
  124. if (oldOnReadyStateChange) {
  125. oldOnReadyStateChange.call(this, event)
  126. }
  127. }
  128.  
  129. oldSend.call(this, body)
  130. }
  131. }
  132.  
  133. /*
  134. * return true if the domain of the supplied URL (string) is t.co, false
  135. * otherwise
  136. */
  137. const isTracked = value => checkUrl(value)?.hostname === TRACKING_DOMAIN
  138.  
  139. /*
  140. * replacement for Twitter's default handler for XHR requests. we transform the
  141. * response if it's a) JSON and b) contains URL data; otherwise, we leave it
  142. * unchanged
  143. */
  144. const onResponse = (xhr, uri) => {
  145. const contentType = xhr.getResponseHeader('Content-Type')
  146.  
  147. if (!CONTENT_TYPE.test(contentType)) {
  148. return
  149. }
  150.  
  151. const url = new URL(uri)
  152.  
  153. // exclude e.g. the config-<date>.json file from pbs.twimg.com, which is the
  154. // second biggest document (~500K) after home_latest.json (~700K)
  155. if (!TWITTER_API.test(url.hostname)) {
  156. return
  157. }
  158.  
  159. const json = xhr.responseText
  160. const size = json.length
  161.  
  162. // fold paths which differ only in the user or query ID, e.g.:
  163. //
  164. // /2/timeline/profile/1234.json -> /2/timeline/profile.json
  165. // /i/api/graphql/abc123/UserTweets -> /i/api/graphql/UserTweets
  166. //
  167. const path = url.pathname
  168. .replace(/\/\d+\.json$/, '.json')
  169. .replace(/^(.+?\/graphql\/)[^\/]+\/(.+)$/, '$1$2')
  170.  
  171. if (URL_BLACKLIST.has(path)) {
  172. return
  173. }
  174.  
  175. let data
  176.  
  177. try {
  178. data = JSON.parse(json)
  179. } catch (e) {
  180. console.error(`Can't parse JSON for ${uri}:`, e)
  181. return
  182. }
  183.  
  184. const newPath = !(path in STATS)
  185. const count = transform(data, path)
  186.  
  187. STATS[path] = (STATS[path] || 0) + count
  188.  
  189. if (!count) {
  190. if (!STATS[path] && size > LOG_THRESHOLD) {
  191. console.debug(`no replacements in ${path} (${size} B)`)
  192. }
  193.  
  194. return
  195. }
  196.  
  197. const descriptor = { value: JSON.stringify(data) }
  198. const clone = GMCompat.export(descriptor)
  199.  
  200. GMCompat.unsafeWindow.Object.defineProperty(xhr, 'responseText', clone)
  201.  
  202. const replacements = 'replacement' + (count === 1 ? '' : 's')
  203.  
  204. console.debug(`${count} ${replacements} in ${path} (${size} B)`)
  205.  
  206. if (newPath) {
  207. console.log(STATS)
  208. }
  209. }
  210.  
  211. /*
  212. * JSON.stringify +replace+ function used by +transform+ to traverse documents
  213. * and update their URL nodes in place.
  214. */
  215. const replacerFor = state => /** @this {any} */ function replacer (key, value) {
  216. if (PRUNE_KEYS.has(key)) {
  217. return null // a terminal value to stop traversal
  218. }
  219.  
  220. if (URL_KEYS.has(key) && isTracked(value)) {
  221. const { seen, unresolved } = state
  222. const expandedUrl = checkUrl(this.expanded_url || this.expanded)
  223.  
  224. if (expandedUrl) {
  225. seen.set(value, expandedUrl)
  226. this[key] = expandedUrl
  227. ++state.count
  228. } else if (seen.has(value)) {
  229. this[key] = seen.get(value)
  230. ++state.count
  231. } else {
  232. let targets = unresolved.get(value)
  233.  
  234. if (!targets) {
  235. unresolved.set(value, targets = [])
  236. }
  237.  
  238. targets.push({ target: this, key })
  239. }
  240. }
  241.  
  242. return value
  243. }
  244.  
  245. /*
  246. * replace t.co URLs with the original URL in all locations in the document
  247. * which may contain them
  248. *
  249. * returns the number of substituted URLs
  250. */
  251. const transform = (data, path) => {
  252. const seen = new Map()
  253. const unresolved = new Map()
  254. const state = { count: 0, seen, unresolved }
  255. const replacer = replacerFor(state)
  256.  
  257. if (Array.isArray(data)) {
  258. JSON.stringify(data, replacer)
  259. } else if (data) {
  260. for (const key of DOCUMENT_ROOTS) {
  261. if (key in data) {
  262. JSON.stringify(data[key], replacer)
  263. }
  264. }
  265. }
  266.  
  267. for (const [url, targets] of unresolved) {
  268. const expandedUrl = seen.get(url)
  269.  
  270. if (expandedUrl) {
  271. for (const target of targets) {
  272. target.target[target.key] = expandedUrl
  273. ++state.count
  274. }
  275.  
  276. unresolved.delete(url)
  277. }
  278. }
  279.  
  280. if (unresolved.size) {
  281. console.warn(`unresolved URIs (${path}):`, Object.fromEntries(state.unresolved))
  282. }
  283.  
  284. return state.count
  285. }
  286.  
  287. /*
  288. * replace the default XHR#send with our custom version, which scans responses
  289. * for tweets and expands their URLs
  290. */
  291. const xhrProto = GMCompat.unsafeWindow.XMLHttpRequest.prototype
  292.  
  293. xhrProto.send = GMCompat.export(hookXHRSend(xhrProto.send))