Twitter Direct

Remove t.co tracking links from Twitter

目前为 2020-07-25 提交的版本。查看 最新版本

  1. // ==UserScript==
  2. // @name Twitter Direct
  3. // @description Remove t.co tracking links from Twitter
  4. // @author chocolateboy
  5. // @copyright chocolateboy
  6. // @version 0.2.0
  7. // @namespace https://github.com/chocolateboy/userscripts
  8. // @license GPL: https://www.gnu.org/copyleft/gpl.html
  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/@chocolateboy/uncommonjs@0.2.0
  14. // @require https://cdn.jsdelivr.net/npm/just-safe-set@2.1.0
  15. // @run-at document-start
  16. // @inject-into auto
  17. // ==/UserScript==
  18.  
  19. const { set } = module.exports // grab the default export from just-safe-set
  20.  
  21. /*
  22. * the domain we expect metadata (JSON) to come from. if responses come from
  23. * this domain, we strip it before passing the document's URI to the transformer.
  24. */
  25. const TWITTER_API = 'api.twitter.com'
  26.  
  27. /*
  28. * default locations to search for URL metadata (arrays of objects) within tweet
  29. * nodes
  30. */
  31. const TWEET_PATHS = [
  32. 'entities.media',
  33. 'entities.urls',
  34. 'extended_entities.media',
  35. 'extended_entities.urls',
  36. ]
  37.  
  38. /*
  39. * default locations to search for URL metadata (arrays of objects) within
  40. * user/profile nodes
  41. */
  42. const USER_PATHS = [
  43. 'entities.description.urls',
  44. 'entities.url.urls',
  45. ]
  46.  
  47. /*
  48. * an immutable array used in various places as a default value. reused to avoid
  49. * unnecessary allocations.
  50. */
  51. const EMPTY_ARRAY = []
  52.  
  53. /*
  54. * paths into the JSON data in which we can find context objects, i.e. objects
  55. * which have an `entities` (and/or `extended_entities`) property which contains
  56. * URL metadata
  57. *
  58. * options:
  59. *
  60. * - uri: optional URI filter: string (equality) or regex (match)
  61. *
  62. * - root: a path (string or array) into the document under which to begin
  63. * searching (required)
  64. *
  65. * - collect: a function which takes a root node and turns it into an array of
  66. * context nodes to scan for URL data (default: Object.values)
  67. *
  68. * - scan: an array of paths to probe for arrays of { url, expanded_url }
  69. * pairs in a context node (default: USER_PATHS)
  70. *
  71. * - assign: an array of paths to string nodes in the context containing
  72. * unexpanded URLs (e.g. for cards inside tweets). these are replaced with
  73. * expanded URLs gathered during the scan (default: EMPTY_ARRAY)
  74. */
  75. const QUERIES = [
  76. {
  77. uri: /\/users\/lookup\.json$/,
  78. root: [], // returns self
  79. },
  80. {
  81. uri: /\/Following$/,
  82. root: 'data.user.following_timeline.timeline.instructions.*.entries.*.content.itemContent.user.legacy',
  83. },
  84. {
  85. uri: /\/Followers$/,
  86. root: 'data.user.followers_timeline.timeline.instructions.*.entries.*.content.itemContent.user.legacy',
  87. },
  88. {
  89. // found in /graphql/<query-id>/UserByScreenName
  90. // used for hovercard data
  91. root: 'data.user.legacy',
  92. collect: Array.of,
  93. },
  94. {
  95. root: 'globalObjects.tweets',
  96. scan: TWEET_PATHS,
  97. assign: [
  98. 'card.binding_values.card_url.string_value',
  99. 'card.url',
  100. ],
  101. },
  102. {
  103. // spotted in list.json and all.json (used for hovercard data).
  104. // may exist in other documents
  105. root: 'globalObjects.tweets.*.card.users.*',
  106. },
  107. {
  108. root: 'globalObjects.users',
  109. },
  110. ]
  111.  
  112. /*
  113. * a pattern which matches the content-type header of responses we scan for
  114. * tweets: "application/json" or "application/json; charset=utf-8"
  115. */
  116. const CONTENT_TYPE = /^application\/json\b/
  117.  
  118. /*
  119. * compatibility shim needed for Violentmonkey:
  120. * https://github.com/violentmonkey/violentmonkey/issues/997#issuecomment-637700732
  121. */
  122. const Compat = { unsafeWindow }
  123.  
  124. /*
  125. * a function which takes an object and a path into that object (a string of
  126. * dot-separated property names or an array of property names) and returns the
  127. * value at that position within the object, or the (optional) default value if
  128. * it can't be reached.
  129. *
  130. * based on just-safe-get by Angus Croll [1] (which in turn is an implementation
  131. * of Lodash's function of the same name), but with added support for
  132. * wildcard props, e.g.:
  133. *
  134. * foo.*.bar.baz.*.quux
  135. *
  136. * is roughly equivalent to:
  137. *
  138. * obj.foo
  139. * |> Object.values(#)
  140. * |> #.flatMap(value => get(value, "bar.baz", []))
  141. * |> Object.values(#)
  142. * |> #.flatMap(value => get(value, "quux", []))
  143. *
  144. * [1] https://www.npmjs.com/package/just-safe-get
  145. */
  146.  
  147. // TODO release as an NPM module (just-safe-get is ES5 only, but this
  148. // requires ES6 for Array#flatMap and Object.values, though both could be
  149. // polyfilled
  150. function get (obj, path, $default) {
  151. if (!obj) {
  152. return $default
  153. }
  154.  
  155. let props, prop
  156.  
  157. if (Array.isArray(path)) {
  158. props = Array.from(path) // clone
  159. } else if (typeof path === 'string') {
  160. props = path.split('.')
  161. } else {
  162. throw new Error('path must be an array or string')
  163. }
  164.  
  165. while (props.length) {
  166. if (!obj) {
  167. return $default
  168. }
  169.  
  170. prop = props.shift()
  171.  
  172. if (prop === '*') {
  173. // Object.values is very forgiving and works with anything that
  174. // can be turned into an object via Object(...), i.e. everything
  175. // but undefined and null, which we've guarded against above.
  176. return Object.values(obj).flatMap(value => {
  177. return get(value, Array.from(props), EMPTY_ARRAY)
  178. })
  179. }
  180.  
  181. obj = obj[prop]
  182.  
  183. if (obj === undefined) {
  184. return $default
  185. }
  186. }
  187.  
  188. return obj
  189. }
  190.  
  191. /*
  192. * replace t.co URLs with the original URL in all locations within the document
  193. * which contain URLs
  194. */
  195. function transformLinks (data, uri) {
  196. const stats = new Map()
  197.  
  198. for (const query of QUERIES) {
  199. const wantUri = query.uri
  200.  
  201. if (wantUri) {
  202. const match = (typeof wantUri === 'string')
  203. ? uri === wantUri
  204. : wantUri.test(uri)
  205.  
  206. if (!match) {
  207. continue
  208. }
  209. }
  210.  
  211. const root = get(data, query.root)
  212.  
  213. // may be an array (e.g. lookup.json)
  214. if (!(root && (typeof root === 'object'))) {
  215. continue
  216. }
  217.  
  218. const {
  219. collect = Object.values,
  220. scan = USER_PATHS,
  221. assign = EMPTY_ARRAY,
  222. } = query
  223.  
  224. const contexts = collect(root)
  225.  
  226. for (const context of contexts) {
  227. const cache = new Map()
  228.  
  229. // scan the context nodes for { url, expanded_url } pairs, replace
  230. // each t.co URL with its expansion, and cache the mappings
  231. for (const path of scan) {
  232. const items = get(context, path, [])
  233.  
  234. for (const item of items) {
  235. cache.set(item.url, item.expanded_url)
  236. item.url = item.expanded_url
  237. stats.set(query.root, (stats.get(query.root) || 0) + 1)
  238. }
  239. }
  240.  
  241. // now pinpoint isolated URLs in the context which don't have a
  242. // corresponding expansion, and replace them using the mappings we
  243. // created during the scan
  244. for (const path of assign) {
  245. const url = get(context, path)
  246.  
  247. if (typeof url === 'string') {
  248. const expandedUrl = cache.get(url)
  249.  
  250. if (expandedUrl) {
  251. set(context, path, expandedUrl)
  252. stats.set(query.root, (stats.get(query.root) || 0) + 1)
  253. }
  254. }
  255. }
  256. }
  257. }
  258.  
  259. if (stats.size) {
  260. // format: "expanded 1 URL in "a.b" and 2 URLs in "c.d" in /2/example.json"
  261. const summary = Array.from(stats).map(([path, count]) => {
  262. const quantity = count === 1 ? '1 URL' : `${count} URLs`
  263. return `${quantity} in ${JSON.stringify(path)}`
  264. }).join(' and ')
  265.  
  266. console.debug(`expanded ${summary} in ${uri}`)
  267. }
  268.  
  269. return data
  270. }
  271.  
  272. /*
  273. * parse and transform the JSON response, handling (catching and logging) any
  274. * errors
  275. */
  276. function transformResponse (json, path) {
  277. let parsed
  278.  
  279. try {
  280. parsed = JSON.parse(json)
  281. } catch (e) {
  282. console.error("Can't parse response:", e)
  283. return
  284. }
  285.  
  286. let transformed
  287.  
  288. try {
  289. transformed = transformLinks(parsed, path)
  290. } catch (e) {
  291. console.error('Error transforming JSON:', e)
  292. return
  293. }
  294.  
  295. return transformed
  296. }
  297.  
  298. /*
  299. * replacement for Twitter's default response handler. we transform the response
  300. * if it's a) JSON and b) contains URL data; otherwise, we leave it unchanged
  301. */
  302. function onReadyStateChange (xhr, url) {
  303. const contentType = xhr.getResponseHeader('Content-Type')
  304.  
  305. if (!CONTENT_TYPE.test(contentType)) {
  306. return
  307. }
  308.  
  309. const parsed = new URL(url)
  310. const path = parsed.hostname === TWITTER_API ? parsed.pathname : parsed.origin + parsed.pathname
  311. const transformed = transformResponse(xhr.responseText, path)
  312.  
  313. if (transformed) {
  314. const descriptor = { value: JSON.stringify(transformed) }
  315. const clone = Compat.cloneInto(descriptor, Compat.unsafeWindow)
  316. Compat.unsafeWindow.Object.defineProperty(xhr, 'responseText', clone)
  317. }
  318. }
  319.  
  320. /*
  321. * replace the built-in XHR#send method with our custom version which swaps in
  322. * our custom response handler. once done, we delegate to the original handler
  323. * (this.onreadystatechange)
  324. */
  325. function hookXHRSend (oldSend) {
  326. return function send () {
  327. const oldOnReadyStateChange = this.onreadystatechange
  328.  
  329. this.onreadystatechange = function () {
  330. if (this.readyState === this.DONE && this.responseURL && this.status === 200) {
  331. onReadyStateChange(this, this.responseURL)
  332. }
  333.  
  334. oldOnReadyStateChange.apply(this, arguments)
  335. }
  336.  
  337. return oldSend.apply(this, arguments)
  338. }
  339. }
  340.  
  341. /*
  342. * set up a cross-engine API to shield us from differences between engines so we
  343. * don't have to clutter the code with conditionals.
  344. *
  345. * XXX the functions are only needed by Violentmonkey for Firefox, and are
  346. * no-ops in other engines
  347. */
  348. if ((typeof cloneInto === 'function') && (typeof exportFunction === 'function')) {
  349. // Greasemonkey 4 (Firefox) and Violentmonkey (Firefox + Chrome)
  350. Object.assign(Compat, { cloneInto, exportFunction })
  351.  
  352. // Violentmonkey for Firefox
  353. if (unsafeWindow.wrappedJSObject) {
  354. Compat.unsafeWindow = unsafeWindow.wrappedJSObject
  355. }
  356. } else {
  357. Compat.cloneInto = Compat.exportFunction = value => value
  358. }
  359.  
  360. /*
  361. * replace the default XHR#send with our custom version, which scans responses
  362. * for tweets and expands their URLs
  363. */
  364. console.debug('hooking XHR#send:', Compat.unsafeWindow.XMLHttpRequest.prototype.send)
  365.  
  366. Compat.unsafeWindow.XMLHttpRequest.prototype.send = Compat.exportFunction(
  367. hookXHRSend(window.XMLHttpRequest.prototype.send),
  368. Compat.unsafeWindow
  369. )