// ==UserScript==
// @name Steam Store Redirector
// @namespace https://rafaelgssa.gitlab.io/monkey-scripts
// @version 5.0.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/405813-monkey-utils/code/Monkey%20Utils.js?version=821710
// @require https://greasyfork.org/scripts/405802-monkey-dom/code/Monkey%20DOM.js?version=821769
// @require https://greasyfork.org/scripts/405831-monkey-storage/code/Monkey%20Storage.js?version=821709
// @require https://greasyfork.org/scripts/405840-monkey-wizard/code/Monkey%20Wizard.js?version=821711
// @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 DOM, PersistentStorage, SettingsWizard, Utils */
/**
* @typedef {DestinationMap[keyof DestinationMap]} Destination
*
* @typedef {Object} DestinationMap
* @property {'0'} STEAM_COMMUNITY
* @property {'1'} STEAM_DB
*
* @typedef {'app' | 'sub'} SteamGameType
*/
(async () => {
'use strict';
const scriptId = 'ssr';
const scriptName = GM.info.script.name;
const DESTINATIONS = /** @type {DestinationMap} */ ({
STEAM_COMMUNITY: '0',
STEAM_DB: '1',
});
const DESTINATION_URLS = /** @type {Record<Destination, string>} */ ({
[DESTINATIONS.STEAM_COMMUNITY]: 'https://steamcommunity.com',
[DESTINATIONS.STEAM_DB]: 'https://steamdb.info',
});
const schemas = /** @type {WizardSchema[]} */ ([
{
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,
},
],
},
]);
const defaultValues = /** @type {StorageValues} */ ({
settings: Object.fromEntries(schemas.map((schema) => [schema.id, schema.defaultValue])),
});
/** @type {MutationObserver} */
let observer;
/**
* Loads the script.
* @returns {Promise<void> | void}
*/
const load = () => {
const matches = window.location.href.match(
/^https:\/\/store\.steampowered\.com\/#(app|sub)_(\d+)/
);
if (matches) {
const [, type, id] = matches;
return redirectGame(/** @type {SteamGameType} */ (type), id);
}
removePageUrlFragment();
checkPageLoaded();
};
/**
* Redirects a game to the appropriate page.
* @param {SteamGameType} 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 PersistentStorage.getSetting(
'destination'
));
const url = DESTINATION_URLS[destination];
window.location.href = `${url}/${type}/${id}`;
};
/**
* 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_'))
) {
return;
}
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 = DOM.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 element.
* @param {Element} contextEl The context element where to add the fragments.
*/
const addUrlFragments = (contextEl) => {
if (!(contextEl instanceof Element)) {
return;
}
let wasAdded = false;
const selectors = [
'[href*="store.steampowered.com/app/"]',
'[href*="store.steampowered.com/sub/"]',
].join(', ');
if (contextEl.matches(selectors)) {
wasAdded = addUrlFragment(/** @type {HTMLAnchorElement} */ (contextEl));
} else {
const elements = Array.from(
/** @type {NodeListOf<HTMLAnchorElement>} */ (contextEl.querySelectorAll(selectors))
);
wasAdded = elements.filter(addUrlFragment).length > 0;
}
if (wasAdded && contextEl === document.body) {
// Keep adding until there are no more links without the fragments.
window.setTimeout(addUrlFragments, Utils.ONE_SECOND_IN_MILLI, contextEl);
}
};
/**
* 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 PersistentStorage.init(scriptId, defaultValues);
await SettingsWizard.init(scriptId, scriptName, schemas);
await load();
} catch (err) {
console.log(`Failed to load ${scriptName}: `, err);
}
})();