Steam Store Redirector

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

// ==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);
	}
})();