Twitter Direct

Remove t.co tracking links from Twitter

当前为 2022-01-01 提交的版本,查看 最新版本

  1. // ==UserScript==
  2. // @name Twitter Direct
  3. // @description Remove t.co tracking links from Twitter
  4. // @author chocolateboy
  5. // @copyright chocolateboy
  6. // @version 2.1.6
  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 densely-populated top-level paths don't contain t.co URLs, e.g.
  28. * $.timeline.
  29. */
  30. const DOCUMENT_ROOTS = [
  31. 'data',
  32. 'globalObjects',
  33. 'inbox_initial_state',
  34. 'users',
  35. ]
  36.  
  37. /*
  38. * keys of "legacy" objects which URL data is known to be found in/under, e.g.
  39. * we're interested in legacy.user_refs.* and legacy.retweeted_status.*, but not
  40. * in legacy.created_at or legacy.reply_count.
  41. *
  42. * legacy objects typically contain dozens of keys, but t.co URLs only exist in
  43. * a handful of them. typically this reduces the number of keys to traverse in a
  44. * legacy object from 30 on average (max 39) to 2 or 3.
  45. */
  46. const LEGACY_KEYS = [
  47. 'binding_values',
  48. 'entities',
  49. 'extended_entities',
  50. 'quoted_status_permalink',
  51. 'retweeted_status',
  52. 'retweeted_status_result',
  53. 'user_refs',
  54. ]
  55.  
  56. /*
  57. * the minimum size (in bytes) of documents we deem to be "not small"
  58. *
  59. * we log (to the console) misses (i.e. no URLs ever found/replaced) in
  60. * documents whose size is greater than or equal to this value
  61. */
  62. const LOG_THRESHOLD = 1024
  63.  
  64. /*
  65. * nodes under these keys never contain t.co URLs so we can speed up traversal
  66. * by pruning (not descending) them
  67. */
  68. const PRUNE_KEYS = new Set([
  69. 'advertiser_account_service_levels',
  70. 'card_platform',
  71. 'clientEventInfo',
  72. 'ext',
  73. 'ext_media_color',
  74. 'features',
  75. 'feedbackInfo',
  76. 'hashtags',
  77. 'original_info',
  78. 'player_image_color',
  79. 'profile_banner_extensions',
  80. 'profile_banner_extensions_media_color',
  81. 'profile_image_extensions',
  82. 'profile_image_extensions_media_color',
  83. 'responseObjects',
  84. 'sizes',
  85. 'user_mentions',
  86. 'video_info',
  87. ])
  88.  
  89. /*
  90. * a map from URI paths (strings) to the replacement count for each path. used
  91. * to keep a running total of the number of replacements in each document type
  92. */
  93. const STATS = {}
  94.  
  95. /*
  96. * a pattern which matches the domain(s) we expect data (JSON) to come from.
  97. * responses which don't come from a matching domain are ignored.
  98. */
  99. const TWITTER_API = /^(?:(?:api|mobile)\.)?twitter\.com$/
  100.  
  101. /*
  102. * a list of document URIs (paths) which are known to not contain t.co URLs and
  103. * which therefore don't need to be processed
  104. */
  105. const URL_BLACKLIST = new Set([
  106. '/i/api/1.1/hashflags.json',
  107. '/i/api/2/badge_count/badge_count.json',
  108. '/i/api/graphql/articleNudgeDomains',
  109. '/i/api/graphql/TopicToFollowSidebar',
  110. ])
  111.  
  112. /*
  113. * object keys whose corresponding values may be t.co URLs
  114. */
  115. const URL_KEYS = new Set(['url', 'string_value'])
  116.  
  117. /*
  118. * return a truthy value (the URL itself) if the supplied value is a valid URL
  119. * (string), falsey otherwise
  120. */
  121. const checkUrl = (function () {
  122. // this is faster than using the URL constructor (in v8), which incurs
  123. // the overhead of using a try/catch block
  124. const urlPattern = /^https?:\/\/\w/i
  125.  
  126. // no need to coerce the value to a string as RegExp#test does that
  127. // automatically
  128. //
  129. // https://tc39.es/ecma262/#sec-regexp.prototype.test
  130. return value => urlPattern.test(value) && value
  131. })()
  132.  
  133. /*
  134. * replace the built-in XHR#send method with a custom version which swaps in our
  135. * custom response handler. once done, we delegate to the original handler
  136. * (this.onreadystatechange)
  137. */
  138. const hookXHRSend = oldSend => {
  139. return /** @this {XMLHttpRequest} */ function send (body = null) {
  140. const oldOnReadyStateChange = this.onreadystatechange
  141.  
  142. this.onreadystatechange = function (event) {
  143. if (this.readyState === this.DONE && this.responseURL && this.status === 200) {
  144. onResponse(this, this.responseURL)
  145. }
  146.  
  147. if (oldOnReadyStateChange) {
  148. oldOnReadyStateChange.call(this, event)
  149. }
  150. }
  151.  
  152. oldSend.call(this, body)
  153. }
  154. }
  155.  
  156. /*
  157. * return true if the supplied value is an array or plain object, false otherwise
  158. */
  159. const isObject = value => value && (typeof value === 'object')
  160.  
  161. /*
  162. * return true if the supplied value is a plain object, false otherwise
  163. *
  164. * only used with JSON data, so doesn't need to be foolproof
  165. */
  166. const isPlainObject = (function () {
  167. const toString = {}.toString
  168. return value => toString.call(value) === '[object Object]'
  169. })()
  170.  
  171. /*
  172. * return true if the supplied value is a t.co URL (string), false otherwise
  173. */
  174. const isTrackedUrl = (function () {
  175. // this is faster (in v8) than using the URL constructor (and a try/catch
  176. // block)
  177. const urlPattern = /^https?:\/\/t\.co\/\w+$/
  178.  
  179. // no need to coerce the value to a string as RegExp#test does that
  180. // automatically
  181. return value => urlPattern.test(value)
  182. })()
  183.  
  184. /*
  185. * replacement for Twitter's default handler for XHR requests. we transform the
  186. * response if it's a) JSON and b) contains URL data; otherwise, we leave it
  187. * unchanged
  188. */
  189. const onResponse = (xhr, uri) => {
  190. const contentType = xhr.getResponseHeader('Content-Type')
  191.  
  192. if (!CONTENT_TYPE.test(contentType)) {
  193. return
  194. }
  195.  
  196. const url = new URL(uri)
  197.  
  198. // exclude e.g. the config-<date>.json file from pbs.twimg.com, which is the
  199. // second biggest document (~500K) after home_latest.json (~700K)
  200. if (!TWITTER_API.test(url.hostname)) {
  201. return
  202. }
  203.  
  204. const json = xhr.responseText
  205. const size = json.length
  206.  
  207. // fold paths which differ only in the user or query ID, e.g.:
  208. //
  209. // /2/timeline/profile/1234.json -> /2/timeline/profile.json
  210. // /i/api/graphql/abc123/UserTweets -> /i/api/graphql/UserTweets
  211. //
  212. const path = url.pathname
  213. .replace(/\/\d+\.json$/, '.json')
  214. .replace(/^(.+?\/graphql\/)[^\/]+\/(.+)$/, '$1$2')
  215.  
  216. if (URL_BLACKLIST.has(path)) {
  217. return
  218. }
  219.  
  220. let data
  221.  
  222. try {
  223. data = JSON.parse(json)
  224. } catch (e) {
  225. console.error(`Can't parse JSON for ${uri}:`, e)
  226. return
  227. }
  228.  
  229. if (!isObject(data)) {
  230. return
  231. }
  232.  
  233. const newPath = !(path in STATS)
  234. const count = transform(data, path)
  235.  
  236. STATS[path] = (STATS[path] || 0) + count
  237.  
  238. if (!count) {
  239. if (!STATS[path] && size > LOG_THRESHOLD) {
  240. console.debug(`no replacements in ${path} (${size} B)`)
  241. }
  242.  
  243. return
  244. }
  245.  
  246. const descriptor = { value: JSON.stringify(data) }
  247. const clone = GMCompat.export(descriptor)
  248.  
  249. GMCompat.unsafeWindow.Object.defineProperty(xhr, 'responseText', clone)
  250.  
  251. const replacements = 'replacement' + (count === 1 ? '' : 's')
  252.  
  253. console.debug(`${count} ${replacements} in ${path} (${size} B)`)
  254.  
  255. if (newPath) {
  256. console.log(STATS)
  257. }
  258. }
  259.  
  260. /*
  261. * JSON.stringify +replace+ function used by +transform+ to traverse documents
  262. * and update their URL nodes in place.
  263. */
  264. const replacerFor = state => /** @this {any} */ function replacer (key, value) {
  265. // exclude subtrees which never contain t.co URLs
  266. if (PRUNE_KEYS.has(key)) {
  267. return 0 // a terminal value to stop traversal
  268. }
  269.  
  270. // we only care about the "card_url" property in binding_values
  271. // objects/arrays. exclude the other 24 properties
  272. if (key === 'binding_values') {
  273. if (Array.isArray(value)) {
  274. const found = value.find(it => it?.key === 'card_url')
  275. return found ? [found] : 0
  276. } else if (isPlainObject(value)) {
  277. return { card_url: (value.card_url || 0) }
  278. } else {
  279. return 0
  280. }
  281. }
  282.  
  283. // reduce the keys under this.legacy (typically around 30) to the handful we
  284. // care about
  285. if (key === 'legacy' && isPlainObject(value)) {
  286. // XXX don't expand legacy.url: leaving it unexpanded results in media
  287. // URLs (e.g. YouTube URLs) appearing as clickable links in the tweet
  288. // (which we want)
  289.  
  290. // we could use an array, but it doesn't appear to be faster (in v8)
  291. const filtered = {}
  292.  
  293. for (let i = 0; i < LEGACY_KEYS.length; ++i) {
  294. const key = LEGACY_KEYS[i]
  295.  
  296. if (key in value) {
  297. filtered[key] = value[key]
  298. }
  299. }
  300.  
  301. return filtered
  302. }
  303.  
  304. // expand t.co URL nodes in place
  305. if (URL_KEYS.has(key) && isTrackedUrl(value)) {
  306. const { seen, unresolved } = state
  307.  
  308. let expandedUrl
  309.  
  310. if ((expandedUrl = seen.get(value))) {
  311. this[key] = expandedUrl
  312. ++state.count
  313. } else if ((expandedUrl = checkUrl(this.expanded_url || this.expanded))) {
  314. seen.set(value, expandedUrl)
  315. this[key] = expandedUrl
  316. ++state.count
  317. } else {
  318. let targets = unresolved.get(value)
  319.  
  320. if (!targets) {
  321. unresolved.set(value, targets = [])
  322. }
  323.  
  324. targets.push({ target: this, key })
  325. }
  326.  
  327. return 0
  328. }
  329.  
  330. // shrink terminals (don't waste space/memory in the (discarded) JSON)
  331. return isObject(value) ? value : 0
  332. }
  333.  
  334. /*
  335. * replace t.co URLs with the original URL in all locations in the document
  336. * which may contain them
  337. *
  338. * returns the number of substituted URLs
  339. */
  340. const transform = (data, path) => {
  341. const seen = new Map()
  342. const unresolved = new Map()
  343. const state = { count: 0, seen, unresolved }
  344. const replacer = replacerFor(state)
  345.  
  346. // [1] top-level tweet or user data (e.g. /favorites/create.json)
  347. if (Array.isArray(data) || ('id_str' in data) /* [1] */) {
  348. JSON.stringify(data, replacer)
  349. } else {
  350. for (const key of DOCUMENT_ROOTS) {
  351. if (key in data) {
  352. JSON.stringify(data[key], replacer)
  353. }
  354. }
  355. }
  356.  
  357. for (const [url, targets] of unresolved) {
  358. const expandedUrl = seen.get(url)
  359.  
  360. if (expandedUrl) {
  361. for (const { target, key } of targets) {
  362. target[key] = expandedUrl
  363. ++state.count
  364. }
  365.  
  366. unresolved.delete(url)
  367. }
  368. }
  369.  
  370. if (unresolved.size) {
  371. console.warn(`unresolved URIs (${path}):`, Object.fromEntries(state.unresolved))
  372. }
  373.  
  374. return state.count
  375. }
  376.  
  377. /*
  378. * replace the default XHR#send with our custom version, which scans responses
  379. * for tweets and expands their URLs
  380. */
  381. const xhrProto = GMCompat.unsafeWindow.XMLHttpRequest.prototype
  382.  
  383. xhrProto.send = GMCompat.export(hookXHRSend(xhrProto.send))

QingJ © 2025

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