Twitter Direct

Remove t.co tracking links from Twitter

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

  1. // ==UserScript==
  2. // @name Twitter Direct
  3. // @description Remove t.co tracking links from Twitter
  4. // @author chocolateboy
  5. // @copyright chocolateboy
  6. // @version 1.7.2
  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/@chocolateboy/uncommonjs@3.1.2/dist/polyfill.iife.min.js
  14. // @require https://unpkg.com/get-wild@1.5.0/dist/index.umd.min.js
  15. // @require https://unpkg.com/gm-compat@1.1.0/dist/index.iife.min.js
  16. // @require https://unpkg.com/just-safe-set@2.2.1/index.js
  17. // @run-at document-start
  18. // ==/UserScript==
  19.  
  20. /*
  21. * a pattern which matches the content-type header of responses we scan for
  22. * URLs: "application/json" or "application/json; charset=utf-8"
  23. */
  24. const CONTENT_TYPE = /^application\/json\b/
  25.  
  26. /*
  27. * the minimum size (in bytes) of documents we deem to be "not small"
  28. *
  29. * we log (to the console) misses (i.e. no URLs ever found/replaced) in
  30. * documents whose size is greater than or equal to this value
  31. *
  32. * if we keep failing to find URLs in large documents, we may be able to speed
  33. * things up by blacklisting them, at least in theory
  34. *
  35. * (in practice, URL data is optional in most of the matched document types
  36. * (contained in arrays that can be empty), so an absence of URLs doesn't
  37. * necessarily mean URL data will never be included...)
  38. */
  39. const LOG_THRESHOLD = 1024
  40.  
  41. /*
  42. * an immutable array used in various places as a way to indicate "no values".
  43. * static to avoid unnecessary allocations.
  44. */
  45. const NONE = []
  46.  
  47. /*
  48. * used to keep track of which queries (don't) have matching URIs and which URIs
  49. * (don't) have matching queries
  50. */
  51. const STATS = { root: {}, uri: {} }
  52.  
  53. /*
  54. * the domain intercepted links are routed through
  55. *
  56. * not all links are intercepted. exceptions include links to twitter (e.g.
  57. * https://twitter.com) and card URIs (e.g. card://123456)
  58. */
  59. const TRACKING_DOMAIN = 't.co'
  60.  
  61. /*
  62. * a pattern which matches the domain(s) we expect data (JSON) to come from.
  63. * responses which don't come from a matching domain are ignored.
  64. */
  65. const TWITTER_API = /^(?:(?:api|mobile)\.)?twitter\.com$/
  66.  
  67. /*
  68. * default locations to search for URL metadata (arrays of objects) within tweet
  69. * nodes
  70. */
  71. const TWEET_PATHS = [
  72. 'entities.media',
  73. 'entities.urls',
  74. 'extended_entities.media',
  75. 'extended_entities.urls',
  76. ]
  77.  
  78. /*
  79. * default locations to search for URL metadata (arrays of objects) within
  80. * user/profile nodes
  81. */
  82. const USER_PATHS = [
  83. 'entities.description.urls',
  84. 'entities.url.urls',
  85. ]
  86.  
  87. /*
  88. * a router which matches URIs (pathnames) to queries. each query contains a
  89. * root path (required) and some additional options which specify the locations
  90. * under the root path to substitute URLs in.
  91. *
  92. * implemented as an array of pairs with URI-pattern keys (string(s) or
  93. * regexp(s)) and one or more queries as the value. if a query is a string
  94. * (path), it is converted into an object with the path as its `root`
  95. * property.
  96. *
  97. * options:
  98. *
  99. * - root (required): a path (string or array of steps) into the document
  100. * under which to begin searching
  101. *
  102. * - collect (default: Object.values): a function which takes a root node and
  103. * turns it into an array of context nodes to scan for URL data
  104. *
  105. * - scan (default: USER_PATHS): an array of paths to probe for arrays of
  106. * { url, expanded_url } pairs in a context node
  107. *
  108. * - targets (default: NONE): an array of paths to standalone URLs (URLs that
  109. * don't have an accompanying expansion), e.g. for URLs in cards embedded in
  110. * tweets. these URLs are replaced by expanded URLs gathered during
  111. * preceding scans.
  112. *
  113. * target paths can point directly to a URL node (string), or to an array
  114. * or plain object, in which case the URL is located inside the array/object
  115. * and replaced
  116. *
  117. * if a target path is an object containing a { url: path, expanded_url: path }
  118. * pair, the URL is expanded directly in the same way as scanned paths.
  119. */
  120. const MATCH = [
  121. [
  122. /\/Bookmarks$/, [
  123. 'data.bookmark_timeline.timeline.instructions.*.entries.*.content.itemContent.tweet.card.legacy.user_refs.*.legacy',
  124. 'data.bookmark_timeline.timeline.instructions.*.entries.*.content.itemContent.tweet.core.user.legacy',
  125. {
  126. root: 'data.bookmark_timeline.timeline.instructions.*.entries.*.content.itemContent.tweet.legacy',
  127. scan: TWEET_PATHS,
  128. targets:[
  129. {
  130. url: 'quoted_status_permalink.url',
  131. expanded_url: 'quoted_status_permalink.expanded',
  132. }
  133. ]
  134. },
  135. {
  136. root: 'data.bookmark_timeline.timeline.instructions.*.entries.*.content.itemContent.tweet.card',
  137.  
  138. // just expand the URLs in the specified locations within the
  139. // card; there's no user or tweet data under this root
  140. scan: NONE,
  141.  
  142. targets: ['legacy.binding_values', 'legacy.url'],
  143. },
  144. ],
  145. ],
  146. [
  147. /\/Conversation$/, [
  148. 'data.conversation_timeline.instructions.*.moduleItems.*.item.itemContent.tweet.core.user.legacy',
  149. 'data.conversation_timeline.instructions.*.entries.*.content.items.*.item.itemContent.tweet.core.user.legacy',
  150. {
  151. root: 'data.conversation_timeline.instructions.*.moduleItems.*.item.itemContent.tweet.legacy',
  152. scan: TWEET_PATHS,
  153. targets: ['card.binding_values', 'card.url'],
  154. },
  155. {
  156. root: 'data.conversation_timeline.instructions.*.entries.*.content.items.*.item.itemContent.tweet.legacy',
  157. scan: TWEET_PATHS,
  158. targets: ['card.binding_values', 'card.url'],
  159. },
  160. ]
  161. ],
  162. [
  163. /\/Favoriters$/,
  164. 'data.favoriters_timeline.timeline.instructions.*.entries.*.content.itemContent.user.legacy',
  165. ],
  166. [
  167. /\/Following$/,
  168. 'data.user.following_timeline.timeline.instructions.*.entries.*.content.itemContent.user.legacy',
  169. ],
  170. [
  171. /\/Followers$/,
  172. 'data.user.followers_timeline.timeline.instructions.*.entries.*.content.itemContent.user.legacy',
  173. ],
  174. [
  175. /\/FollowersYouKnow$/,
  176. 'data.user.friends_following_timeline.timeline.instructions.*.entries.*.content.itemContent.user.legacy',
  177. ],
  178. [
  179. // "List" page, e.g. /i/api/graphql/abcd1234/ListLatestTweetsTimeline
  180. /\/ListLatestTweetsTimeline$/, [
  181. 'data.list.tweets_timeline.timeline.instructions.*.entries.*.content.itemContent.tweet.core.user.legacy',
  182. {
  183. root: 'data.list.tweets_timeline.timeline.instructions.*.entries.*.content.itemContent.tweet.legacy',
  184. scan: TWEET_PATHS,
  185. },
  186. {
  187. root: 'data.list.tweets_timeline.timeline.instructions.*.entries.*.content.itemContent.tweet.legacy.retweeted_status.legacy',
  188. scan: TWEET_PATHS,
  189. },
  190. {
  191. root: 'data.list.tweets_timeline.timeline.instructions.*.entries.*.content.itemContent.tweet',
  192. scan: NONE,
  193. targets: [
  194. 'card.legacy.binding_values',
  195. 'card.legacy.url',
  196. 'legacy.retweeted_status.card.legacy.binding_values',
  197. 'legacy.retweeted_status.card.legacy.url',
  198. ],
  199. },
  200. ]
  201. ],
  202. [
  203. // "Likes" page, e.g. /i/api/graphql/abcd1234/Likes
  204. /\/Likes$/, [
  205. 'data.user.result.timeline.timeline.instructions.*.entries.*.content.itemContent.tweet.core.user.legacy',
  206. 'data.user.result.timeline.timeline.instructions.*.entries.*.content.itemContent.tweet.quoted_status.card.legacy.user_refs.*.legacy',
  207. 'data.user.result.timeline.timeline.instructions.*.entries.*.content.itemContent.tweet.quoted_status.core.user.legacy',
  208. {
  209. root: 'data.user.result.timeline.timeline.instructions.*.entries.*.content.itemContent.tweet.quoted_status.legacy',
  210. scan: TWEET_PATHS,
  211. },
  212. {
  213. root: 'data.user.result.timeline.timeline.instructions.*.entries.*.content.itemContent.tweet.legacy',
  214. scan: TWEET_PATHS,
  215. targets: [
  216. {
  217. url: 'quoted_status_permalink.url',
  218. expanded_url: 'quoted_status_permalink.expanded',
  219. },
  220. ],
  221. },
  222. {
  223. root: 'data.user.result.timeline.timeline.instructions.*.entries.*.content.itemContent.tweet',
  224. scan: NONE,
  225. targets: [
  226. 'card.legacy.binding_values',
  227. 'card.legacy.url',
  228. 'quoted_status.card.legacy.binding_values',
  229. 'quoted_status.card.legacy.url',
  230. ],
  231. },
  232. ],
  233. ],
  234. [
  235. /\/ListMembers$/,
  236. 'data.list.members_timeline.timeline.instructions.*.entries.*.content.itemContent.user.legacy'
  237. ],
  238. [
  239. /\/ListSubscribers$/,
  240. 'data.list.subscribers_timeline.timeline.instructions.*.entries.*.content.itemContent.user.legacy',
  241. ],
  242. [
  243. /\/Retweeters/,
  244. 'data.retweeters_timeline.timeline.instructions.*.entries.*.content.itemContent.user.legacy'
  245. ],
  246. [
  247. // used for hovercard data
  248. /\/UserByScreenName$/, {
  249. root: 'data.user.legacy',
  250. collect: Array.of,
  251. }
  252. ],
  253. [
  254. /\/UserByScreenNameWithoutResults$/, {
  255. root: 'data.user.legacy',
  256. collect: Array.of,
  257. },
  258. ],
  259. [
  260. // e.g. /i/api/graphql/abcd1234/UserMedia
  261. /\/UserMedia$/, [
  262. 'data.user.result.timeline.timeline.instructions.*.entries.*.content.itemContent.tweet.card.legacy.user_refs.*.legacy',
  263. 'data.user.result.timeline.timeline.instructions.*.entries.*.content.itemContent.tweet.core.user.legacy',
  264. 'data.user.result.timeline.timeline.instructions.*.entries.*.content.itemContent.tweet.quoted_status.card.legacy.user_refs.*.legacy',
  265. {
  266. root: 'data.user.result.timeline.timeline.instructions.*.entries.*.content.itemContent.tweet.legacy',
  267. scan: TWEET_PATHS,
  268. },
  269. {
  270. root: 'data.user.result.timeline.timeline.instructions.*.entries.*.content.itemContent.tweet.quoted_status.legacy',
  271. scan: TWEET_PATHS,
  272. },
  273. ]
  274. ],
  275. [
  276. // e.g. /i/api/graphql/abcd1234/UserTweets
  277. /\/UserTweets$/, [
  278. 'data.user.result.timeline.timeline.instructions.*.entries.*.content.itemContent.tweet.core.user.legacy',
  279. 'data.user.result.timeline.timeline.instructions.*.entries.*.content.itemContent.tweet.legacy.retweeted_status.core.user.legacy',
  280. 'data.user.result.timeline.timeline.instructions.*.entries.*.content.items.*.item.itemContent.tweet.core.user.legacy',
  281. 'data.user.result.timeline.timeline.instructions.*.entries.*.content.items.*.item.itemContent.user.legacy',
  282. 'data.user.result.timeline.timeline.instructions.*.entry.content.itemContent.tweet.core.user.legacy',
  283. {
  284. root: 'data.user.legacy',
  285. collect: Array.of,
  286. },
  287. {
  288. root: 'data.user.result.timeline.timeline.instructions.*.entries.*.content.itemContent.tweet.legacy',
  289. scan: TWEET_PATHS,
  290. },
  291. {
  292. root: 'data.user.result.timeline.timeline.instructions.*.entries.*.content.itemContent.tweet.legacy.retweeted_status.legacy',
  293. scan: TWEET_PATHS,
  294. },
  295. {
  296. root: 'data.user.result.timeline.timeline.instructions.*.entry.content.itemContent.tweet.legacy',
  297. scan: TWEET_PATHS,
  298. },
  299. {
  300. root: 'data.user.result.timeline.timeline.instructions.*.entries.*.content.itemContent.tweet.card',
  301. scan: NONE,
  302. targets: ['legacy.binding_values', 'legacy.url'],
  303. },
  304. {
  305. root: 'data.user.result.timeline.timeline.instructions.*.entries.*.content.itemContent.tweet.legacy.retweeted_status.card',
  306. scan: NONE,
  307. targets: ['legacy.binding_values', 'legacy.url'],
  308. },
  309. ]
  310. ],
  311. [
  312. // e.g. /i/api/graphql/abcd1234/UserTweetsAndReplies
  313. /\/UserTweetsAndReplies$/, [
  314. 'data.user.result.timeline.timeline.instructions.*.entries.*.content.itemContent.tweet.core.user.legacy',
  315. 'data.user.result.timeline.timeline.instructions.*.entries.*.content.itemContent.tweet.legacy.retweeted_status.core.user.legacy',
  316. 'data.user.result.timeline.timeline.instructions.*.entries.*.content.items.*.item.itemContent.tweet.core.user.legacy',
  317. 'data.user.result.timeline.timeline.instructions.*.entry.content.itemContent.tweet.core.user.legacy',
  318. {
  319. root: 'data.user.result.timeline.timeline.instructions.*.entries.*.content.items.*.item.itemContent.tweet.legacy',
  320. scan: TWEET_PATHS,
  321. },
  322. {
  323. root: 'data.user.result.timeline.timeline.instructions.*.entries.*.content.itemContent.tweet.legacy',
  324. scan: TWEET_PATHS,
  325. },
  326. {
  327. root: 'data.user.result.timeline.timeline.instructions.*.entries.*.content.itemContent.tweet.legacy.retweeted_status.legacy',
  328. scan: TWEET_PATHS,
  329. },
  330. {
  331. root: 'data.user.result.timeline.timeline.instructions.*.entry.content.itemContent.tweet.legacy',
  332. scan: TWEET_PATHS,
  333. },
  334. {
  335. root: 'data.user.result.timeline.timeline.instructions.*.entries.*.content.items.*.item.itemContent.tweet.card',
  336. scan: NONE,
  337. targets: ['legacy.binding_values', 'legacy.url'],
  338. },
  339. {
  340. root: 'data.user.result.timeline.timeline.instructions.*.entries.*.content.itemContent.tweet.card',
  341. scan: NONE,
  342. targets: ['legacy.binding_values', 'legacy.url'],
  343. },
  344. {
  345. root: 'data.user.result.timeline.timeline.instructions.*.entries.*.content.itemContent.tweet.legacy.retweeted_status.card',
  346. scan: NONE,
  347. targets: ['legacy.binding_values', 'legacy.url'],
  348. },
  349. ]
  350. ],
  351. [
  352. // DMs
  353. // e.g. '/1.1/dm/inbox_initial_state.json' and '/1.1/dm/user_updates.json'
  354. /\/(?:inbox_initial_state|user_updates)\.json$/, {
  355. root: 'inbox_initial_state.entries.*.message.message_data',
  356. scan: TWEET_PATHS,
  357. targets: ['attachment.card.binding_values', 'attachment.card.url'],
  358. }
  359. ],
  360. [
  361. // e.g. '/1.1/friends/following/list.json',
  362. /\/list\.json$/, {
  363. root: 'users.*'
  364. },
  365. ],
  366. [
  367. // e.g. '/1.1/users/lookup.json',
  368. /\/lookup\.json$/, {
  369. root: NONE, // the document itself (an array of users) is the root
  370. }
  371. ],
  372. [
  373. // "Who to follow"
  374. // e.g. '/1.1/users/recommendations.json'
  375. /\/recommendations\.json$/, {
  376. root: '*.user',
  377. }
  378. ],
  379. ]
  380.  
  381. /*
  382. * a single { pattern => queries } pair for the router which is run against all
  383. * documents
  384. */
  385. const WILDCARD = [
  386. /./,
  387. [
  388. 'globalObjects.users',
  389. 'globalObjects.tweets.*.card.users',
  390. {
  391. root: 'globalObjects.tweets',
  392. scan: TWEET_PATHS,
  393. targets: [
  394. {
  395. url: 'card.binding_values.website_shortened_url.string_value',
  396. expanded_url: 'card.binding_values.website_url.string_value',
  397. },
  398. 'card.binding_values',
  399. 'card.url',
  400. ],
  401. },
  402. ]
  403. ]
  404.  
  405. /*
  406. * a custom version of get-wild's `get` function which uses a simpler/faster
  407. * path parser since we don't use the extended syntax
  408. */
  409. const get = exports.getter({ split: '.' })
  410.  
  411. /*
  412. * a helper function which returns true if the supplied value is a plain object,
  413. * false otherwise
  414. */
  415. const isPlainObject = (function () {
  416. const toString = {}.toString
  417. // only used with JSON data, so we don't need this to be foolproof
  418. return value => toString.call(value) === '[object Object]'
  419. })()
  420.  
  421. /*
  422. * a helper function which iterates over the supplied iterable, filtering out
  423. * missing (undefined) values.
  424. *
  425. * this is done in one pass (rather than map + filter) as there may potentially
  426. * be dozens or even hundreds of values, e.g. contexts (tweet/user objects)
  427. * under a root node
  428. */
  429. function eachDefined (iterable, fn) {
  430. for (const value of iterable) {
  431. if (value) fn(value)
  432. }
  433. }
  434.  
  435. /**
  436. * a helper function which returns true if the supplied URL is tracked by
  437. * Twitter, false otherwise
  438. */
  439. function isTracked (url) {
  440. return (new URL(url)).hostname === TRACKING_DOMAIN
  441. }
  442.  
  443. /*
  444. * JSON.stringify helper used to serialize stats data
  445. */
  446. function replacer (_key, value) {
  447. return (value instanceof Set) ? Array.from(value) : value
  448. }
  449.  
  450. /*
  451. * a generator which returns { pattern => queries } pairs where patterns are
  452. * strings/regexps which match a URI and queries are objects which define
  453. * substitutions to perform in the matched document.
  454. *
  455. * this forms the basis of a simple "router" which tries all URI patterns until
  456. * one matches (or none match) and then additionally performs a wildcard match
  457. * which works on all URIs.
  458. *
  459. * the URI patterns are disjoint, so there's no need to try them all if one
  460. * matches. in addition to these, some substitutions are non URI-specific, i.e.
  461. * they work on documents that aren't matched by URI (e.g. profile.json) and
  462. * documents that are (e.g. list.json). currently these cross-document nodes can
  463. * be found under obj.globalObjects, so we check for the existence of that
  464. * property before yielding these catch-all queries
  465. */
  466. function* router (data, state) {
  467. for (const [key, value] of MATCH) {
  468. yield [key, value]
  469.  
  470. if (state.matched) {
  471. break
  472. }
  473. }
  474.  
  475. if ('globalObjects' in data) {
  476. yield WILDCARD
  477. }
  478. }
  479.  
  480. /*
  481. * a helper class which implements document-specific (MATCH) and generic
  482. * (WILDCARD) URL substitutions in nodes (subtrees) within a JSON-formatted
  483. * document returned by the Twitter API.
  484. *
  485. * a transformer is instantiated for each query and its methods are passed a
  486. * context (node within the document tree) and the value of an option from the
  487. * query, e.g. the `scan` option is handled by the `_scan` method and the
  488. * `targets` option is processed by the `_assign` method
  489. */
  490. class Transformer {
  491. constructor ({ cache, onReplace, root, uri }) {
  492. this._cache = cache
  493. this._onReplace = onReplace
  494. this._root = root
  495. this._uri = uri
  496. }
  497.  
  498. /*
  499. * expand URLs in context nodes in the locations specified by the query's
  500. * `scan` and `targets` options
  501. */
  502. // @ts-ignore https://github.com/microsoft/TypeScript/issues/14279
  503. transform (contexts, scan, targets) {
  504. // scan the context nodes for { url, expanded_url } pairs, replace
  505. // each t.co URL with its expansion, and add the mappings to the
  506. // cache
  507. eachDefined(contexts, context => this._scan(context, scan))
  508.  
  509. // do a separate pass for targets because the scan may have added more
  510. // mappings to the cache
  511. if (targets.length) {
  512. eachDefined(contexts, context => this._assign(context, targets))
  513. }
  514. }
  515.  
  516. /*
  517. * scan the context node for { url, expanded_url } pairs, replace each t.co
  518. * URL with its expansion, and add the mappings to the cache
  519. */
  520. _scan (context, paths) {
  521. const { _cache: cache, _onReplace: onReplace } = this
  522.  
  523. for (const path of paths) {
  524. const items = get(context, path, NONE)
  525.  
  526. for (const item of items) {
  527. if (item.url && item.expanded_url) {
  528. if (isTracked(item.url)) {
  529. cache.set(item.url, item.expanded_url)
  530. item.url = item.expanded_url
  531. onReplace()
  532. }
  533. } else {
  534. console.warn("can't find url/expanded_url pair for:", {
  535. uri: this._uri,
  536. root: this._root,
  537. path,
  538. item,
  539. })
  540. }
  541. }
  542. }
  543. }
  544.  
  545. /*
  546. * replace URLs in the context which weren't substituted during the scan.
  547. *
  548. * these are either standalone URLs whose expansion we retrieve from the
  549. * cache, or URLs whose expansion exists in the context in a location not
  550. * covered by the scan
  551. */
  552. _assign (context, targets) {
  553. for (const target of targets) {
  554. if (isPlainObject(target)) {
  555. this._assignFromPath(context, target)
  556. } else {
  557. this._assignFromCache(context, target)
  558. }
  559. }
  560. }
  561.  
  562. /*
  563. * replace a short URL in the context with an expanded URL defined in the
  564. * context, e.g. context.foo.url = context.bar.baz.expanded_url
  565. *
  566. * this is similar to the replacements performed during the scan, but rather
  567. * than using a fixed set of locations/paths, the paths to the short and
  568. * expanded URLs are supplied as a parameter
  569. */
  570. _assignFromPath (context, paths) {
  571. const { url: urlPath, expanded_url: expandedUrlPath } = paths
  572.  
  573. let url, expandedUrl
  574.  
  575. if (
  576. (url = get(context, urlPath))
  577. && isTracked(url)
  578. && (expandedUrl = get(context, expandedUrlPath))
  579. ) {
  580. this._cache.set(url, expandedUrl)
  581. exports.set(context, urlPath, expandedUrl)
  582. this._onReplace()
  583. }
  584. }
  585.  
  586. /*
  587. * pinpoint an isolated URL in the context which doesn't have a
  588. * corresponding expansion, and replace it using the mappings collected
  589. * during preceding scans
  590. */
  591. _assignFromCache (context, path) {
  592. const node = get(context, path)
  593.  
  594. let url
  595.  
  596. // special-case card URLs
  597. //
  598. // if the target is an array or plain object, locate its target URL
  599. // automatically. used to resolve "binding" nodes, which represent
  600. // properties (key/value pairs) as an array or object
  601.  
  602. if (Array.isArray(node)) {
  603. const found = node.find(it => it?.key === 'card_url')
  604.  
  605. if (found) {
  606. context = found
  607. path = 'value.string_value'
  608. url = get(context, path)
  609. }
  610. } else if (isPlainObject(node)) {
  611. if (node.card_url) {
  612. context = node
  613. path = 'card_url.string_value'
  614. url = get(context, path)
  615. }
  616. } else {
  617. url = node
  618. }
  619.  
  620. if (typeof url === 'string' && isTracked(url)) {
  621. const expandedUrl = this._cache.get(url)
  622.  
  623. if (expandedUrl) {
  624. exports.set(context, path, expandedUrl)
  625. this._onReplace()
  626. } else {
  627. console.warn(`can't find expanded URL for ${url} in ${this._uri}`)
  628. }
  629. }
  630. }
  631. }
  632.  
  633. /*
  634. * replace t.co URLs with the original URL in all locations in the document
  635. * which contain URLs
  636. *
  637. * returns the number of substituted URLs
  638. */
  639. function transform (data, uri) {
  640. let count = 0 // keep track of and return the number of expanded URLs
  641.  
  642. if (!STATS.uri[uri]) {
  643. STATS.uri[uri] = new Set()
  644. }
  645.  
  646. // t.co -> expanded URL mapping for all queries in this document. used to
  647. // fill in isolated URLs (usually in cards) which don't have a corresponding
  648. // `expanded_url`
  649. const cache = new Map()
  650.  
  651. // used to notify the router when a pattern matches so it can stop trying
  652. // URI matches and transition to the wildcard match
  653. const state = { matched: false }
  654.  
  655. // an iterator which yields { pattern => query } pairs for this document. if
  656. // a document's URI matches the pattern, the corresponding queries (search +
  657. // replace) are executed
  658. const it = router(data, state)
  659.  
  660. for (const [key, value] of it) {
  661. const uris = NONE.concat(key) // coerce to an array
  662. const queries = NONE.concat(value)
  663. const match = uris.some(want => {
  664. return (typeof want === 'string') ? (uri === want) : want.test(uri)
  665. })
  666.  
  667. if (match) {
  668. // stop matching URIs after this and switch to the wildcard queries
  669. state.matched = true
  670. } else {
  671. // try the next URI pattern, or switch to the wildcard queries if
  672. // there are no more patterns to match against
  673. continue
  674. }
  675.  
  676. for (const $query of queries) {
  677. const query = isPlainObject($query) ? $query : { root: $query }
  678. const { root: rootPath } = query
  679.  
  680. if (!STATS.root[rootPath]) {
  681. STATS.root[rootPath] = new Set()
  682. }
  683.  
  684. const root = get(data, rootPath)
  685.  
  686. // might be an array (e.g. lookup.json)
  687. if (!root || typeof root !== 'object') {
  688. continue
  689. }
  690.  
  691. const {
  692. collect = Object.values,
  693. scan = USER_PATHS,
  694. targets = NONE,
  695. } = query
  696.  
  697. const updateStats = () => {
  698. ++count
  699. STATS.uri[uri].add(rootPath)
  700. STATS.root[rootPath].add(uri)
  701. }
  702.  
  703. const contexts = collect(root)
  704.  
  705. const transformer = new Transformer({
  706. cache,
  707. onReplace: updateStats,
  708. root: rootPath,
  709. uri,
  710. })
  711.  
  712. // @ts-ignore https://github.com/microsoft/TypeScript/issues/14279
  713. transformer.transform(contexts, scan, targets)
  714. }
  715. }
  716.  
  717. return count
  718. }
  719.  
  720. /*
  721. * replacement for Twitter's default handler for XHR requests. we transform the
  722. * response if it's a) JSON and b) contains URL data; otherwise, we leave it
  723. * unchanged
  724. */
  725. function onResponse (xhr, uri) {
  726. const contentType = xhr.getResponseHeader('Content-Type')
  727.  
  728. if (!CONTENT_TYPE.test(contentType)) {
  729. return
  730. }
  731.  
  732. const url = new URL(uri)
  733.  
  734. // exclude e.g. the config-<date>.json file from pbs.twimg.com, which is the
  735. // second biggest document (~500K) after home_latest.json (~700K)
  736. if (!TWITTER_API.test(url.hostname)) {
  737. return
  738. }
  739.  
  740. const json = xhr.responseText
  741. const size = json.length
  742.  
  743. // fold URIs which differ only in the user ID, e.g.:
  744. // /2/timeline/profile/1234.json -> /2/timeline/profile.json
  745. const path = url.pathname.replace(/\/\d+\.json$/, '.json')
  746.  
  747. let data
  748.  
  749. try {
  750. data = JSON.parse(json)
  751. } catch (e) {
  752. console.error(`Can't parse JSON for ${uri}:`, e)
  753. return
  754. }
  755.  
  756. const oldStats = JSON.stringify(STATS, replacer)
  757. const count = transform(data, path)
  758.  
  759. if (!count) {
  760. if (STATS.uri[path].size === 0 && size >= LOG_THRESHOLD) {
  761. console.debug(`no replacements in ${path} (${size} B)`)
  762. }
  763.  
  764. return
  765. }
  766.  
  767. const descriptor = { value: JSON.stringify(data) }
  768. const clone = GMCompat.export(descriptor)
  769.  
  770. GMCompat.unsafeWindow.Object.defineProperty(xhr, 'responseText', clone)
  771.  
  772. const newStats = JSON.stringify(STATS, replacer)
  773.  
  774. if (newStats !== oldStats) {
  775. const replacements = 'replacement' + (count === 1 ? '' : 's')
  776. console.debug(`${count} ${replacements} in ${path} (${size} B)`)
  777. console.log(JSON.parse(newStats))
  778. }
  779. }
  780.  
  781. /*
  782. * replace the built-in XHR#send method with our custom version which swaps in
  783. * our custom response handler. once done, we delegate to the original handler
  784. * (this.onreadystatechange)
  785. */
  786. function hookXHRSend (oldSend) {
  787. return /** @this {XMLHttpRequest} */ function send (body = null) {
  788. const oldOnReadyStateChange = this.onreadystatechange
  789.  
  790. this.onreadystatechange = function (event) {
  791. if (this.readyState === this.DONE && this.responseURL && this.status === 200) {
  792. onResponse(this, this.responseURL)
  793. }
  794.  
  795. if (oldOnReadyStateChange) {
  796. oldOnReadyStateChange.call(this, event)
  797. }
  798. }
  799.  
  800. oldSend.call(this, body)
  801. }
  802. }
  803.  
  804. /*
  805. * replace the default XHR#send with our custom version, which scans responses
  806. * for tweets and expands their URLs
  807. */
  808. const xhrProto = GMCompat.unsafeWindow.XMLHttpRequest.prototype
  809.  
  810. xhrProto.send = GMCompat.export(hookXHRSend(xhrProto.send))

QingJ © 2025

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