Twitter Direct

Remove t.co tracking links from Twitter

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

  1. // ==UserScript==
  2. // @name Twitter Direct
  3. // @description Remove t.co tracking links from Twitter
  4. // @author chocolateboy
  5. // @copyright chocolateboy
  6. // @version 0.8.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@2.0.1/index.min.js
  14. // @require https://unpkg.com/get-wild@1.2.0/dist/index.umd.min.js
  15. // @require https://unpkg.com/just-safe-set@2.1.0/index.js
  16. // @require https://cdn.jsdelivr.net/gh/chocolateboy/gm-compat@a26896b85770aa853b2cdaf2ff79029d8807d0c0/index.min.js
  17. // @run-at document-start
  18. // @inject-into auto
  19. // ==/UserScript==
  20.  
  21. /*
  22. * the domain we expect data (JSON) to come from. responses that aren't from
  23. * this domain are ignored.
  24. */
  25. const TWITTER_API = 'api.twitter.com'
  26.  
  27. /*
  28. * the domain intercepted links are routed through
  29. *
  30. * not all links are intercepted. exceptions include links to twitter (e.g.
  31. * https://twitter.com) and card URIs (e.g. card://123456)
  32. */
  33. const TRACKING_DOMAIN = 't.co'
  34.  
  35. /*
  36. * default locations to search for URL metadata (arrays of objects) within tweet
  37. * nodes
  38. */
  39. const TWEET_PATHS = [
  40. 'entities.media',
  41. 'entities.urls',
  42. 'extended_entities.media',
  43. 'extended_entities.urls',
  44. ]
  45.  
  46. /*
  47. * default locations to search for URL metadata (arrays of objects) within
  48. * user/profile nodes
  49. */
  50. const USER_PATHS = [
  51. 'entities.description.urls',
  52. 'entities.url.urls',
  53. ]
  54.  
  55. /*
  56. * an immutable array used in various places as a way to indicate "no values".
  57. * static to avoid unnecessary allocations.
  58. */
  59. const NONE = []
  60.  
  61. /*
  62. * paths into the JSON data in which we can find context objects, i.e. objects
  63. * which have an `entities` (and/or `extended_entities`) property which contains
  64. * URL metadata
  65. *
  66. * options:
  67. *
  68. * - uri: optional URI filter: one or more strings (equality) or regexps (match)
  69. *
  70. * - root (required): a path (string or array of steps) into the document
  71. * under which to begin searching
  72. *
  73. * - collect (default: Object.values): a function which takes a root node and
  74. * turns it into an array of context nodes to scan for URL data
  75. *
  76. * - scan (default: USER_PATHS): an array of paths to probe for arrays of
  77. * { url, expanded_url } pairs in a context node
  78. *
  79. * - targets (default: NONE): an array of paths to standalone URLs (URLs that
  80. * don't have an accompanying expansion), e.g. for URLs in cards embedded in
  81. * tweets. these URLs are replaced by expanded URLs gathered during the
  82. * scan.
  83. *
  84. * target paths can point directly to a URL node (string) or to an
  85. * array of objects. in the latter case, we find the URL object in the array
  86. * (obj.key === "card_url") and replace its URL node (obj.value.string_value)
  87. *
  88. * if a target path is an object containing a { url: path, expanded_url: path }
  89. * pair, the URL is expanded directly in the same way as scanned paths.
  90. */
  91. const QUERIES = [
  92. {
  93. // e.g. '/1.1/users/lookup.json',
  94. uri: /\/lookup\.json$/,
  95. root: [], // returns self
  96. },
  97. {
  98. uri: /\/Conversation$/,
  99. root: 'data.conversation_timeline.instructions.*.moduleItems.*.item.itemContent.tweet.core.user.legacy',
  100. },
  101. {
  102. uri: /\/Conversation$/,
  103. root: 'data.conversation_timeline.instructions.*.entries.*.content.items.*.item.itemContent.tweet.core.user.legacy',
  104. },
  105. {
  106. uri: /\/Conversation$/,
  107. root: 'data.conversation_timeline.instructions.*.moduleItems.*.item.itemContent.tweet.legacy',
  108. scan: TWEET_PATHS,
  109. targets: ['card.binding_values', 'card.url'],
  110. },
  111. {
  112. uri: /\/Conversation$/,
  113. root: 'data.conversation_timeline.instructions.*.entries.*.content.items.*.item.itemContent.tweet.legacy',
  114. scan: TWEET_PATHS,
  115. targets: ['card.binding_values', 'card.url'],
  116. },
  117. {
  118. uri: /\/Following$/,
  119. root: 'data.user.following_timeline.timeline.instructions.*.entries.*.content.itemContent.user.legacy',
  120. },
  121. {
  122. uri: /\/Followers$/,
  123. root: 'data.user.followers_timeline.timeline.instructions.*.entries.*.content.itemContent.user.legacy',
  124. },
  125. {
  126. // used for hovercard data
  127. uri: /\/UserByScreenName$/,
  128. root: 'data.user.legacy',
  129. collect: Array.of,
  130. },
  131. { // DMs
  132. // e.g. '/1.1/dm/inbox_initial_state.json' and '/1.1/dm/user_updates.json'
  133. uri: [/\/inbox_initial_state\.json$/, /\/user_updates\.json$/],
  134. root: 'inbox_initial_state.entries.*.message.message_data',
  135. scan: TWEET_PATHS,
  136. targets: [
  137. 'attachment.card.binding_values.card_url.string_value',
  138. 'attachment.card.url',
  139. ],
  140. },
  141. {
  142. // e.g. '/1.1/friends/following/list.json',
  143. uri: /\/list\.json$/,
  144. root: 'users.*',
  145. },
  146. {
  147. root: 'globalObjects.tweets',
  148. scan: TWEET_PATHS,
  149. targets: [
  150. {
  151. url: 'card.binding_values.website_shortened_url.string_value',
  152. expanded_url: 'card.binding_values.website_url.string_value',
  153. },
  154. 'card.binding_values.card_url.string_value',
  155. 'card.url',
  156. ],
  157. },
  158. {
  159. root: 'globalObjects.tweets.*.card.users.*',
  160. },
  161. {
  162. root: 'globalObjects.users',
  163. },
  164. ]
  165.  
  166. /*
  167. * a pattern which matches the content-type header of responses we scan for
  168. * URLs: "application/json" or "application/json; charset=utf-8"
  169. */
  170. const CONTENT_TYPE = /^application\/json\b/
  171.  
  172. /*
  173. * the minimum size (in bytes) of documents we deem to be "not small"
  174. *
  175. * we log misses (i.e. no URLs ever found/replaced) in documents whose size is
  176. * greater than or equal to this value
  177. *
  178. * if we keep failing to find URLs in large documents, we may be able to speed
  179. * things up by blacklisting them, at least in theory
  180. *
  181. * (in practice, URL data is optional in most of the matched document types
  182. * (contained in arrays that can be empty), so an absence of URLs doesn't
  183. * necessarily mean URL data will never be included...)
  184. */
  185. const LOG_THRESHOLD = 1024
  186.  
  187. /*
  188. * used to keep track of which roots (don't) have matching URIs and which URIs
  189. * (don't) have matching roots
  190. */
  191. const STATS = { root: {}, uri: {} }
  192.  
  193. /*
  194. * a custom version of get-wild's `get` function which uses a simpler/faster
  195. * path parser since we don't use the extended syntax
  196. */
  197. const get = exports.getter({ split: '.' })
  198.  
  199. /**
  200. * a helper function which returns true if the supplied URL is tracked by
  201. * Twitter, false otherwise
  202. */
  203. function isTracked (url) {
  204. return (new URL(url)).hostname === TRACKING_DOMAIN
  205. }
  206.  
  207. /*
  208. * JSON.stringify helper used to serialize stats data
  209. */
  210. function replacer (key, value) {
  211. return (value instanceof Set) ? Array.from(value) : value
  212. }
  213.  
  214. /*
  215. * replace t.co URLs with the original URL in all locations in the document
  216. * which contain URLs
  217. */
  218. function transformLinks (json, uri) {
  219. let data, count = 0
  220.  
  221. if (!STATS.uri[uri]) {
  222. STATS.uri[uri] = new Set()
  223. }
  224.  
  225. try {
  226. data = JSON.parse(json)
  227. } catch (e) {
  228. console.error(`Can't parse JSON for ${uri}:`, e)
  229. return
  230. }
  231.  
  232. for (const query of QUERIES) {
  233. if (query.uri) {
  234. const uris = [].concat(query.uri)
  235. const match = uris.some(want => {
  236. return (typeof want === 'string')
  237. ? (uri === want)
  238. : want.test(uri)
  239. })
  240.  
  241. if (!match) {
  242. continue
  243. }
  244. }
  245.  
  246. if (!STATS.root[query.root]) {
  247. STATS.root[query.root] = new Set()
  248. }
  249.  
  250. const root = get(data, query.root)
  251.  
  252. // may be an array (e.g. lookup.json)
  253. if (!root || typeof root !== 'object') {
  254. continue
  255. }
  256.  
  257. const updateStats = () => {
  258. ++count
  259. STATS.uri[uri].add(query.root)
  260. STATS.root[query.root].add(uri)
  261. }
  262.  
  263. const {
  264. collect = Object.values,
  265. scan = USER_PATHS,
  266. targets = NONE,
  267. } = query
  268.  
  269. const cache = new Map()
  270. const contexts = collect(root)
  271.  
  272. for (const context of contexts) {
  273. if (!context) {
  274. continue
  275. }
  276.  
  277. // scan the context nodes for { url, expanded_url } pairs, replace
  278. // each t.co URL with its expansion, and add the mappings to the
  279. // cache
  280. for (const path of scan) {
  281. const items = get(context, path, NONE)
  282.  
  283. for (const item of items) {
  284. if (item.url && item.expanded_url) {
  285. if (isTracked(item.url)) {
  286. cache.set(item.url, item.expanded_url)
  287. item.url = item.expanded_url
  288. updateStats()
  289. }
  290. } else {
  291. console.warn("can't find url/expanded_url pair for:", {
  292. uri,
  293. root: query.root,
  294. path,
  295. item,
  296. })
  297. }
  298. }
  299. }
  300. }
  301.  
  302. if (!targets.length) {
  303. continue
  304. }
  305.  
  306. // do a separate pass for targets because some nested card URLs are
  307. // expanded in other (earlier) tweets under the same root
  308. for (const context of contexts) {
  309. for (const targetPath of targets) {
  310. // this is similar to the url/expanded_url pairs handled in the
  311. // scan, but with custom property-names (paths)
  312. if (targetPath && typeof targetPath === 'object') {
  313. const { url: urlPath, expanded_url: expandedUrlPath } = targetPath
  314.  
  315. let url, expandedUrl
  316.  
  317. if (
  318. (url = get(context, urlPath))
  319. && isTracked(url)
  320. && (expandedUrl = get(context, expandedUrlPath))
  321. ) {
  322. cache.set(url, expandedUrl)
  323. exports.set(context, urlPath, expandedUrl)
  324. updateStats()
  325. }
  326.  
  327. continue
  328. }
  329.  
  330. // pinpoint isolated URLs in the context which don't have a
  331. // corresponding expansion, and replace them using the mappings
  332. // we collected during the scan
  333.  
  334. let url, $context = context, $targetPath = targetPath
  335.  
  336. const node = get(context, targetPath)
  337.  
  338. // if the target points to an array rather than a string, locate
  339. // the URL object within the array automatically
  340. if (Array.isArray(node)) {
  341. if ($context = node.find(it => it.key === 'card_url')) {
  342. $targetPath = 'value.string_value'
  343. url = get($context, $targetPath)
  344. }
  345. } else {
  346. url = node
  347. }
  348.  
  349. if (typeof url === 'string' && isTracked(url)) {
  350. const expandedUrl = cache.get(url)
  351.  
  352. if (expandedUrl) {
  353. exports.set($context, $targetPath, expandedUrl)
  354. updateStats()
  355. } else {
  356. console.warn(`can't find expanded URL for ${url} in ${uri}`)
  357. }
  358. }
  359. }
  360. }
  361. }
  362.  
  363. return { count, data }
  364. }
  365.  
  366. /*
  367. * replacement for Twitter's default response handler. we transform the response
  368. * if it's a) JSON and b) contains URL data; otherwise, we leave it unchanged
  369. */
  370. function onResponse (xhr, uri) {
  371. const contentType = xhr.getResponseHeader('Content-Type')
  372.  
  373. if (!CONTENT_TYPE.test(contentType)) {
  374. return
  375. }
  376.  
  377. const url = new URL(uri)
  378.  
  379. // exclude e.g. the config-<date>.json file from pbs.twimg.com, which is the
  380. // second biggest document (~500K) after home_latest.json (~700K)
  381. if (url.hostname !== TWITTER_API) {
  382. return
  383. }
  384.  
  385. const json = xhr.responseText
  386. const size = json.length
  387.  
  388. // fold URIs which differ only in the user ID, e.g.:
  389. // /2/timeline/profile/1234.json -> /2/timeline/profile.json
  390. const path = url.pathname.replace(/\/\d+\.json$/, '.json')
  391.  
  392. const oldStats = JSON.stringify(STATS, replacer)
  393. const transformed = transformLinks(json, path)
  394.  
  395. let count
  396.  
  397. if (transformed && (count = transformed.count)) {
  398. const descriptor = { value: JSON.stringify(transformed.data) }
  399. const clone = GMCompat.export(descriptor)
  400.  
  401. GMCompat.unsafeWindow.Object.defineProperty(xhr, 'responseText', clone)
  402. }
  403.  
  404. if (count) {
  405. const newStats = JSON.stringify(STATS, replacer)
  406.  
  407. if (newStats !== oldStats) {
  408. const replacements = 'replacement' + (count === 1 ? '' : 's')
  409. console.debug(`${count} ${replacements} in ${path} (${size} B)`)
  410. console.log(JSON.parse(newStats))
  411. }
  412. } else if (STATS.uri[path].size === 0 && size >= LOG_THRESHOLD) {
  413. console.debug(`no replacements in ${path} (${size} B)`)
  414. }
  415. }
  416.  
  417. /*
  418. * replace the built-in XHR#send method with our custom version which swaps in
  419. * our custom response handler. once done, we delegate to the original handler
  420. * (this.onreadystatechange)
  421. */
  422. function hookXHRSend (oldSend) {
  423. return function send () {
  424. const oldOnReadyStateChange = this.onreadystatechange
  425.  
  426. this.onreadystatechange = function () {
  427. if (this.readyState === this.DONE && this.responseURL && this.status === 200) {
  428. onResponse(this, this.responseURL)
  429. }
  430.  
  431. return oldOnReadyStateChange.apply(this, arguments)
  432. }
  433.  
  434. return oldSend.apply(this, arguments)
  435. }
  436. }
  437.  
  438. /*
  439. * replace the default XHR#send with our custom version, which scans responses
  440. * for tweets and expands their URLs
  441. */
  442. GMCompat.unsafeWindow.XMLHttpRequest.prototype.send = GMCompat.export(
  443. hookXHRSend(XMLHttpRequest.prototype.send)
  444. )

QingJ © 2025

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