Twitter Prime

Free yourself from X ads and analytics.

  1. // ==UserScript==
  2. // @name Twitter Prime
  3. // @description Free yourself from X ads and analytics.
  4. // @icon https://abs.twimg.com/favicons/twitter.2.ico
  5. // @namespace Itsnotlupus Industries
  6. // @author itsnotlupus
  7. // @license MIT
  8. // @version 1.5
  9. // @match https://twitter.com/*
  10. // @match https://platform.twitter.com/*
  11. // @match https://x.com/*
  12. // @match https://platform.x.com/*
  13. // @run-at document-start
  14. // @require https://update.greatest.deepsurf.us/scripts/468394/1247001/utils.js
  15. // @require https://update.greatest.deepsurf.us/scripts/472943/1320613/middleman.js
  16. // @require https://update.greatest.deepsurf.us/scripts/473998/1246974/react-tools.js
  17. // ==/UserScript==
  18. /* jshint esversion:11 */
  19. /* eslint-env es2020 */
  20. /* global ReactTools, middleMan, traverse, decodeEntities, logGroup, log */
  21.  
  22. // NOTE: You can edit the following config flags to taste.
  23. const CONFIG = {
  24. // This setting hides upsell, subscriptions, grok and super follow nonsense.
  25. hide_upselling: true,
  26. // When set to true, this setting will show a "Community Notes" tab.
  27. show_community_notes_tag: false,
  28. // When set to true, this settings will show a "Spaces" tab to find Spaces to listen to.
  29. // The UX is a bit wonky on desktop, which might be why it's hidden. Still, it's neat.
  30. show_spaces_tab: false
  31. };
  32.  
  33. // Apply our config settings to a Twitter user config object.
  34. function applyConfigOverrides(obj) {
  35. // disable any config key that has a whiff of monetization
  36. Object.keys(obj.config).forEach(key => key.match(/_upsell|subscriptions_|super_follow/) && (obj.config[key] = { value: !CONFIG.hide_upselling }));
  37.  
  38. // I use this to browse for new relevant config values, sometimes.
  39. // const logConfig = Object.assign({},obj.config);
  40. // Object.keys(logConfig).forEach(key => key.match(/_upsell|subscriptions_|super_follow/) || (logConfig[key].value == false) && (delete logConfig[key]));
  41. // log("Twitter Config", logConfig);
  42.  
  43. // other optional fun config tweaks
  44. return Object.assign(obj.config, {
  45. responsive_web_birdwatch_note_writing_enabled: { value: CONFIG.show_community_notes_tag },
  46. voice_rooms_discovery_page_enabled: { value: CONFIG.show_spaces_tab },
  47. });
  48. }
  49.  
  50. // Replace t.co shortened links with real URLs
  51. function unshortenLinks(obj) {
  52. const map = {};
  53. // 1st pass: gather associations between t.co and actual URLs
  54. traverse(obj, (obj) => {
  55. if (obj && obj.url && obj.expanded_url) map[obj.url] = obj.expanded_url;
  56. });
  57. // 2d pass: replace (almost) any string that contains a t.co URL
  58. traverse(obj, (str, parent, key) => {
  59. if (typeof str == 'string' && map[str] && key !== 'full_text') parent[key] = map[str];
  60. });
  61. return obj;
  62. }
  63.  
  64. // Log removed ads to the console, for the curious cats among us.
  65. function logAd(obj) {
  66. const { itemContent } = obj.content ?? obj.item;
  67. const { name, screen_name } = itemContent.promotedMetadata.advertiser_results?.result?.legacy ?? {};
  68. const { result = {} } = itemContent.tweet_results;
  69. const { full_text, id_str } = result.legacy ?? result.tweet?.legacy ?? {};
  70. const url = `https://twitter.com/${screen_name}/status/${id_str}`;
  71. logGroup(`[AD REMOVED] @${screen_name} ${url}`, `From ${name}`, decodeEntities(full_text));
  72. }
  73.  
  74. // Why struggle to remove ads/promoted tweets from X's html tag soup when you can simply remove them from the wire?
  75. function removeAds(obj) {
  76. traverse(obj, (obj, parent, key) => {
  77. if (obj && (obj.content ?? obj.item)?.itemContent?.promotedMetadata) {
  78. logAd(obj);
  79. delete parent[key];
  80. return false;
  81. }
  82. });
  83. return obj;
  84. }
  85.  
  86. // Twitter has degraded and currently loads tweets from users that blocked you when looking at the timeline of someone who replied to those tweets.
  87. // This attempts to prevent the blocked tweets from getting tombstoned when expanding them.
  88. function avoidTombstonePayloads(obj) {
  89. log("avoidTombstonePayloads", obj);
  90. if (obj?.data?.threaded_conversation_with_injections_v2?.instructions?.[0]?.entries?.[0]?.content?.itemContent?.tweet_results?.result?.tombstone) {
  91. // still a bit too brutal, doesn't allow replies to load. XXX
  92. // we might need to store items fetched previously and inject them in instructions payload to replace tombstones instead of simply deleting them.
  93. obj?.data?.threaded_conversation_with_injections_v2?.instructions.shift();
  94. }
  95. return obj;
  96. }
  97.  
  98. function expandAllTweets(obj) {
  99. return obj; // doesn't work well enough to enable by default.
  100. log("expandAllTweets", obj);
  101. traverse(obj, (obj, parent, key) => {
  102. if (obj && obj.note_tweet?.is_expandable && obj.note_tweet?.note_tweet_results?.result?.text) {
  103. obj.full_text = obj.note_tweet.note_tweet_results.result.text;
  104. obj.legacy.full_text = obj.full_text;
  105. obj.legacy.display_text_range[1] = obj.full_text.length;
  106. delete obj.note_tweet;
  107. log("Replaced full tweet:", obj.full_text);
  108. // doesn't fully work XXX. the twitter frontend truncates at around 500-650 characters anyway.
  109. }
  110. });
  111. return obj;
  112. }
  113.  
  114. // Middleman response handler generator. Just add a json editing function.
  115. function transformResponse(transform) {
  116. return async (req, res, err) => {
  117. if (err) return;
  118.  
  119. return Response.json(transform(await res.json()), {
  120. status: res.status,
  121. headers: res.headers
  122. });
  123. };
  124. }
  125.  
  126. function main() {
  127. // Disable google analytics
  128. (globalThis.unsafeWindow??window).ga = (method, field, details) => {};
  129.  
  130. // Mutate Twitter's redux store. This is widely seen as poor form, as it skips/breaks most of redux' logic.
  131. ReactTools.withReduxState(state => applyConfigOverrides(state.featureSwitch.user));
  132.  
  133. // Intercept requests that would invalidate our config flags
  134. const processSettings = transformResponse(applyConfigOverrides);
  135. middleMan.addHook("https://api.twitter.com/1.1/help/settings.json?*", { responseHandler: processSettings });
  136.  
  137. // Intercept twitter API calls to use real URLs and remove ads.
  138. const processTwitterJSON = transformResponse(obj => removeAds(unshortenLinks(obj)));
  139. middleMan.addHook("https://twitter.com/i/api/graphql/*", { responseHandler: processTwitterJSON });
  140. middleMan.addHook("https://twitter.com/i/api/*.json?*", { responseHandler: processTwitterJSON });
  141. middleMan.addHook("https://cdn.syndication.twimg.com/tweet-result?*", { responseHandler: processTwitterJSON });
  142.  
  143. // Twitter doesn't *need* to know what's happening in your browser. They'd like to, but maybe you have a say too.
  144. // The next line means "If you see a network request like this, short-circuit it and return an empty response instead."
  145. middleMan.addHook("https://twitter.com/i/api/1.1/jot/*", { requestHandler: () => new Response() });
  146.  
  147. // Don't bother loading tombstones. Things work "better" without them.
  148. const showBlockedTweetsSometimes = transformResponse(avoidTombstonePayloads);
  149. middleMan.addHook("https://twitter.com/i/api/graphql/*/TweetDetail?*", { responseHandler: showBlockedTweetsSometimes });
  150.  
  151. // Attempt to auto expand tweets by default. Not currently working.
  152. middleMan.addHook("https://twitter.com/i/api/graphql/*/TweetDetail?*", { responseHandler: transformResponse(expandAllTweets) });
  153. }
  154.  
  155. main();