Steam Store Redirector

Redirects removed games from the Steam store to SteamCommunity or SteamDB.

  1. // ==UserScript==
  2. // @name Steam Store Redirector
  3. // @namespace https://rafaelgssa.gitlab.io/monkey-scripts
  4. // @version 5.0.1
  5. // @author rafaelgssa
  6. // @description Redirects removed games from the Steam store to SteamCommunity or SteamDB.
  7. // @match *://*/*
  8. // @require https://greasemonkey.github.io/gm4-polyfill/gm4-polyfill.js
  9. // @require https://greasyfork.org/scripts/405813-monkey-utils/code/Monkey%20Utils.js?version=821710
  10. // @require https://greasyfork.org/scripts/405802-monkey-dom/code/Monkey%20DOM.js?version=821769
  11. // @require https://greasyfork.org/scripts/405831-monkey-storage/code/Monkey%20Storage.js?version=821709
  12. // @require https://greasyfork.org/scripts/405840-monkey-wizard/code/Monkey%20Wizard.js?version=821711
  13. // @run-at document-start
  14. // @grant GM.info
  15. // @grant GM.setValue
  16. // @grant GM.getValue
  17. // @grant GM.deleteValue
  18. // @grant GM_info
  19. // @grant GM_setValue
  20. // @grant GM_getValue
  21. // @grant GM_deleteValue
  22. // @noframes
  23. // ==/UserScript==
  24.  
  25. /* global DOM, PersistentStorage, SettingsWizard, Utils */
  26.  
  27. /**
  28. * @typedef {DestinationMap[keyof DestinationMap]} Destination
  29. *
  30. * @typedef {Object} DestinationMap
  31. * @property {'0'} STEAM_COMMUNITY
  32. * @property {'1'} STEAM_DB
  33. *
  34. * @typedef {'app' | 'sub'} SteamGameType
  35. */
  36.  
  37. (async () => {
  38. 'use strict';
  39.  
  40. const scriptId = 'ssr';
  41. const scriptName = GM.info.script.name;
  42.  
  43. const DESTINATIONS = /** @type {DestinationMap} */ ({
  44. STEAM_COMMUNITY: '0',
  45. STEAM_DB: '1',
  46. });
  47.  
  48. const DESTINATION_URLS = /** @type {Record<Destination, string>} */ ({
  49. [DESTINATIONS.STEAM_COMMUNITY]: 'https://steamcommunity.com',
  50. [DESTINATIONS.STEAM_DB]: 'https://steamdb.info',
  51. });
  52.  
  53. const schemas = /** @type {WizardSchema[]} */ ([
  54. {
  55. type: 'multi',
  56. id: 'destination',
  57. message: 'Where do you want to be redirected to?',
  58. defaultValue: DESTINATIONS.STEAM_COMMUNITY,
  59. choices: [
  60. {
  61. id: '0',
  62. template: "'%' for SteamCommunity",
  63. value: DESTINATIONS.STEAM_COMMUNITY,
  64. },
  65. {
  66. id: '1',
  67. template: "'%' for SteamDB",
  68. value: DESTINATIONS.STEAM_DB,
  69. },
  70. ],
  71. },
  72. ]);
  73.  
  74. const defaultValues = /** @type {StorageValues} */ ({
  75. settings: Object.fromEntries(schemas.map((schema) => [schema.id, schema.defaultValue])),
  76. });
  77.  
  78. /** @type {MutationObserver} */
  79. let observer;
  80.  
  81. /**
  82. * Loads the script.
  83. * @returns {Promise<void> | void}
  84. */
  85. const load = () => {
  86. const matches = window.location.href.match(
  87. /^https:\/\/store\.steampowered\.com\/#(app|sub)_(\d+)/
  88. );
  89. if (matches) {
  90. const [, type, id] = matches;
  91. return redirectGame(/** @type {SteamGameType} */ (type), id);
  92. }
  93. removePageUrlFragment();
  94. checkPageLoaded();
  95. };
  96.  
  97. /**
  98. * Redirects a game to the appropriate page.
  99. * @param {SteamGameType} type The Steam type of the game.
  100. * @param {string} id The Steam ID of the game.
  101. * @returns {Promise<void>}
  102. */
  103. const redirectGame = async (type, id) => {
  104. const destination = /** @type {Destination} */ (await PersistentStorage.getSetting(
  105. 'destination'
  106. ));
  107. const url = DESTINATION_URLS[destination];
  108. window.location.href = `${url}/${type}/${id}`;
  109. };
  110.  
  111. /**
  112. * Removes the fragment from the page URL.
  113. */
  114. const removePageUrlFragment = () => {
  115. if (
  116. window.location.hostname !== 'store.steampowered.com' ||
  117. (!window.location.hash.includes('#app_') && !window.location.hash.includes('#sub_'))
  118. ) {
  119. return;
  120. }
  121. window.history.replaceState(
  122. '',
  123. document.title,
  124. `${window.location.origin}${window.location.pathname}${window.location.search}`
  125. );
  126. };
  127.  
  128. /**
  129. * Checks if the page is fully loaded.
  130. */
  131. const checkPageLoaded = () => {
  132. document.removeEventListener('pjax:end', checkPageLoaded);
  133. document.removeEventListener('turbolinks:load', checkPageLoaded);
  134. if (document.readyState === 'loading') {
  135. document.addEventListener('DOMContentLoaded', onPageLoad);
  136. } else {
  137. onPageLoad();
  138. }
  139. };
  140.  
  141. /**
  142. * Triggered when the page is fully loaded.
  143. */
  144. const onPageLoad = () => {
  145. document.removeEventListener('DOMContentLoaded', onPageLoad);
  146. addUrlFragments(document.body);
  147. if (observer) {
  148. observer.disconnect();
  149. }
  150. observer = DOM.observeNode(document.body, null, /** @type {NodeCallback} */ (addUrlFragments));
  151. document.addEventListener('pjax:end', checkPageLoaded);
  152. document.addEventListener('turbolinks:load', checkPageLoaded);
  153. };
  154.  
  155. /**
  156. * Adds the URL fragments to links in a context element.
  157. * @param {Element} contextEl The context element where to add the fragments.
  158. */
  159. const addUrlFragments = (contextEl) => {
  160. if (!(contextEl instanceof Element)) {
  161. return;
  162. }
  163. let wasAdded = false;
  164. const selectors = [
  165. '[href*="store.steampowered.com/app/"]',
  166. '[href*="store.steampowered.com/sub/"]',
  167. ].join(', ');
  168. if (contextEl.matches(selectors)) {
  169. wasAdded = addUrlFragment(/** @type {HTMLAnchorElement} */ (contextEl));
  170. } else {
  171. const elements = Array.from(
  172. /** @type {NodeListOf<HTMLAnchorElement>} */ (contextEl.querySelectorAll(selectors))
  173. );
  174. wasAdded = elements.filter(addUrlFragment).length > 0;
  175. }
  176. if (wasAdded && contextEl === document.body) {
  177. // Keep adding until there are no more links without the fragments.
  178. window.setTimeout(addUrlFragments, Utils.ONE_SECOND_IN_MILLI, contextEl);
  179. }
  180. };
  181.  
  182. /**
  183. * Adds the URL fragment to a link, if not already exists.
  184. * @param {HTMLAnchorElement} link The link where to add the fragment.
  185. * @returns {boolean} Whether the fragment was added or not.
  186. */
  187. const addUrlFragment = (link) => {
  188. const url = link.getAttribute('href');
  189. let fragment = link.dataset[scriptId];
  190. if (!url || (fragment && url.includes(fragment))) {
  191. return false;
  192. }
  193. const matches = url.match(/(app|sub)\/(\d+)/);
  194. if (!matches) {
  195. return false;
  196. }
  197. const [, type, id] = matches;
  198. fragment = `#${type}_${id}`;
  199. link.href = `${url.replace(/#.*/, '')}${fragment}`;
  200. link.dataset[scriptId] = fragment;
  201. return true;
  202. };
  203.  
  204. try {
  205. await PersistentStorage.init(scriptId, defaultValues);
  206. await SettingsWizard.init(scriptId, scriptName, schemas);
  207. await load();
  208. } catch (err) {
  209. console.log(`Failed to load ${scriptName}: `, err);
  210. }
  211. })();