// ==UserScript==
// @name Steam Store Redirector
// @namespace https://rafaelgssa.gitlab.io/monkey-scripts
// @version 4.1.1
// @author rafaelgssa
// @description Redirects removed games from the Steam store to SteamCommunity or SteamDB.
// @match *://*/*
// @require https://greasemonkey.github.io/gm4-polyfill/gm4-polyfill.js
// @require https://greasyfork.org/scripts/405802-monkey-dom/code/Monkey%20DOM.js?version=819513
// @require https://greasyfork.org/scripts/405831-monkey-storage/code/Monkey%20Storage.js?version=819508
// @require https://greasyfork.org/scripts/405813-monkey-utils/code/Monkey%20Utils.js?version=819175
// @require https://greasyfork.org/scripts/405840-monkey-wizard/code/Monkey%20Wizard.js?version=819509
// @run-at document-start
// @grant GM.info
// @grant GM.setValue
// @grant GM.getValue
// @grant GM.deleteValue
// @grant GM_info
// @grant GM_setValue
// @grant GM_getValue
// @grant GM_deleteValue
// @noframes
// ==/UserScript==
/* global MonkeyDom, MonkeyStorage, MonkeyWizard */
/**
* @typedef {DestinationMap[keyof DestinationMap]} Destination
* @typedef {Object} DestinationMap
* @property {'0'} STEAM_COMMUNITY
* @property {'1'} STEAM_DB
* @typedef {'app' | 'sub'} SteamType
*/
(async () => {
'use strict';
const scriptId = 'ssr';
const scriptName = GM.info.script.name;
/** @type {DestinationMap} */
const DESTINATIONS = {
STEAM_COMMUNITY: '0',
STEAM_DB: '1',
};
/** @type {WizardSchema[]} */
const schemas = [
{
type: 'multi',
id: 'destination',
message: 'Where do you want to be redirected to?',
defaultValue: DESTINATIONS.STEAM_COMMUNITY,
choices: [
{
id: '0',
template: '"%" for SteamCommunity',
value: DESTINATIONS.STEAM_COMMUNITY,
},
{
id: '1',
template: '"%" for SteamDB',
value: DESTINATIONS.STEAM_DB,
},
],
},
];
/** @type {MutationObserver} */
let observer;
/**
* Loads the script.
* @returns {Promise<void>}
*/
const load = async () => {
const matches = window.location.href.match(
/^https:\/\/store\.steampowered\.com\/#(app|sub)_(\d+)/
);
if (matches) {
const [, type, id] = matches;
await _redirectGame(/** @type {SteamType} */ (type), id);
} else {
_removePageUrlFragment();
_checkPageLoaded();
}
};
/**
* Redirects a game to the appropriate page.
* @param {SteamType} type The Steam type of the game.
* @param {string} id The Steam ID of the game.
* @returns {Promise<void>}
*/
const _redirectGame = async (type, id) => {
const destination = /** @type {Destination} */ (await MonkeyStorage.getSetting('destination'));
const url = _getDestinationUrl(destination);
window.location.href = `${url}/${type}/${id}`;
};
/**
* Returns the URL for a destination.
* @param {Destination} destination The destination.
* @returns {string} The URL for the destination.
*/
const _getDestinationUrl = (destination) => {
/** @type {{ [K in Destination]: string }} */
const urls = {
[DESTINATIONS.STEAM_COMMUNITY]: 'https://steamcommunity.com',
[DESTINATIONS.STEAM_DB]: 'https://steamdb.info',
};
return urls[destination];
};
/**
* Removes the fragment from the page URL.
*/
const _removePageUrlFragment = () => {
if (
window.location.hostname === 'store.steampowered.com' &&
(window.location.hash.includes('#app_') || window.location.hash.includes('#sub_'))
) {
window.history.replaceState(
'',
document.title,
`${window.location.origin}${window.location.pathname}${window.location.search}`
);
}
};
/**
* Checks if the page is fully loaded.
*/
const _checkPageLoaded = () => {
document.removeEventListener('pjax:end', _checkPageLoaded);
document.removeEventListener('turbolinks:load', _checkPageLoaded);
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', _onPageLoad);
} else {
_onPageLoad();
}
};
/**
* Triggered when the page is fully loaded.
*/
const _onPageLoad = () => {
document.removeEventListener('DOMContentLoaded', _onPageLoad);
_addUrlFragments(document.body);
if (observer) {
observer.disconnect();
}
observer = MonkeyDom.observeNode(
document.body,
null,
/** @type {NodeCallback} */ (_addUrlFragments)
);
document.addEventListener('pjax:end', _checkPageLoaded);
document.addEventListener('turbolinks:load', _checkPageLoaded);
};
/**
* Adds the URL fragments to links in a context.
* @param {Element} context The context where to add the fragments.
*/
const _addUrlFragments = (context) => {
if (!(context instanceof Element)) {
return;
}
let wasAdded = false;
const selectors = [
'[href*="store.steampowered.com/app/"]',
'[href*="store.steampowered.com/sub/"]',
].join(', ');
if (context.matches(selectors)) {
wasAdded = _addUrlFragment(/** @type {HTMLAnchorElement} */ (context));
} else {
/** @type {NodeListOf<HTMLAnchorElement>} */
const elements = context.querySelectorAll(selectors);
wasAdded = Array.from(elements).filter(_addUrlFragment).length > 0;
}
if (wasAdded && context === document.body) {
// Keep adding until there are no more links without the fragments.
window.setTimeout(_addUrlFragments, 1000, context);
}
};
/**
* Adds the URL fragment to a link, if not already exists.
* @param {HTMLAnchorElement} link The link where to add the fragment.
* @returns {boolean} Whether the fragment was added or not.
*/
const _addUrlFragment = (link) => {
const url = link.getAttribute('href');
let fragment = link.dataset[scriptId];
if (!url || (fragment && url.includes(fragment))) {
return false;
}
const matches = url.match(/(app|sub)\/(\d+)/);
if (!matches) {
return false;
}
const [, type, id] = matches;
fragment = `#${type}_${id}`;
link.href = `${url.replace(/#.*/, '')}${fragment}`;
link.dataset[scriptId] = fragment;
return true;
};
try {
await MonkeyStorage.init(scriptId, {
settings: {
destination: DESTINATIONS.STEAM_COMMUNITY,
},
});
await MonkeyWizard.init(scriptId, scriptName, schemas);
await load();
} catch (err) {
console.log(`Failed to load ${scriptName}: `, err);
}
})();