NOTICE: By continued use of this site you understand and agree to the binding Terms of Service and Privacy Policy.
// ==UserScript== // @name AllKeysShop // @description better wishlist management and Steam integration // @version 0.1.0-rc.1 // @copyright 2021, William Artero (https://artero.dev) // @license MIT; https://opensource.org/licenses/MIT // @icon https://www.allkeyshop.com/blog/wp-content/themes/aks-theme/assets/image/favicon-32x32.png // @namespace https://www.allkeyshop.com // @homepageURL https://github.com/wwmoraes/userscripts // @supportURL https://github.com/wwmoraes/userscripts/issues // @contributionURL https://github.com/wwmoraes/userscripts // @updateURL https://openuserjs.org/meta/wwmoraes/AllKeysShop.meta.js // @downloadURL https://openuserjs.org/src/scripts/wwmoraes/AllKeysShop.user.js // ==OpenUserJS== // @author wwmoraes // ==/OpenUserJS== // @match https://www.allkeyshop.com/* // @connect ipinfo.io // @connect steamid.io // @connect store.steampowered.com // @grant GM_registerMenuCommand // @grant GM_xmlhttpRequest // ==/UserScript== "use strict"; (() => { const isRecord = (data) => data !== null && typeof data === "object"; const isSteamWishlistEntry = (data) => isRecord(data) && typeof data.name === "string"; const isSteamWishlistData = (data) => isRecord(data) && Object.keys(data).every(key => isSteamWishlistEntry(data[key])); const isAKSWishlistImportSteamResponse = (data) => isRecord(data) && typeof data.type === "string" && typeof data.data !== "undefined" && Array.isArray(data.data) && data.data.every(entry => typeof entry === "string"); const fetchSteamWishlistData = (steam64ID) => new Promise((resolve, reject) => GM_xmlhttpRequest({ method: "GET", url: `https://store.steampowered.com/wishlist/profiles/${steam64ID}/wishlistdata/?p=0`, onload: resolve, onerror: reject, })); const fetchCountryCode = () => new Promise((resolve, reject) => GM_xmlhttpRequest({ method: "GET", url: "https://ipinfo.io/country", onload: resolve, onerror: reject, })); const fetchSteamSearchSuggestion = (query, countryCode) => new Promise((resolve, reject) => GM_xmlhttpRequest({ method: "GET", url: `https://store.steampowered.com/search/suggest?term=${encodeURIComponent(query)}&f=games&l=english&&cc=${countryCode}&category1=998&excluded_content_descriptors%5B0%5D=3&excluded_content_descriptors%5B1%5D=4`, onload: resolve, onerror: reject, })); const fetchSteam64ID = (input) => new Promise((resolve, reject) => GM_xmlhttpRequest({ method: "POST", url: "https://steamid.io/lookup", data: `input=${input}`, onload: resolve, onerror: reject, })); /** * searches the Steam store games * * @param query term to search for * @param countryCode user country code * @returns dictionary of app IDs and the respective game name */ const searchSteamGame = async (query, countryCode) => { const response = await fetchSteamSearchSuggestion(query, countryCode); const responseDocument = new DOMParser().parseFromString(response.responseText, "text/html"); return Array.from(responseDocument.querySelectorAll("[data-ds-appid]")).reduce((entries, element) => { const appid = element.getAttribute("data-ds-appid"); if (appid === null) return entries; const nameElement = element.querySelector(".match_name"); if (nameElement === null || nameElement.textContent === null) return entries; entries[appid] = nameElement.textContent; return entries; }, {}); }; const dbEventCallback = (resolve, reject) => (event) => { if (event.target === null) return reject(event); if (typeof event.target.result === "undefined") return reject(event); return resolve(event.target.result); }; const tryGetLocalStorageValue = async (key, fallback) => { let value = localStorage.getItem(key); if (value === null) { value = await fallback(); localStorage.setItem(key, value); } return value; }; const getCountryCode = async () => { const key = "countryCode"; let countryCode = localStorage.getItem(key); if (countryCode === null) { const response = await fetchCountryCode(); countryCode = response.responseText.trim(); localStorage.setItem(key, countryCode); } return countryCode; }; const getSteam64ID = () => tryGetLocalStorageValue("steam64ID", async () => { const id = prompt("what's your current Steam user name?"); if (id === null) throw new Error("user name not provided"); const response = await fetchSteam64ID(id); const responseDocument = new DOMParser().parseFromString(response.responseText, "text/html"); const canonicalElement = responseDocument.querySelector("head link[rel=canonical]"); if (canonicalElement === null) throw new Error("unable to get Steam64 ID"); const url = new URL(canonicalElement.href); const steam64ID = url.pathname.split("/").pop(); if (typeof steam64ID === "undefined") throw new Error("unable to get Steam64 ID"); return steam64ID; }); const sanitizeQuery = (query) => query.replaceAll("&", " ").replaceAll(".", " "); const showGameSelector = async (parent, query, countryCode, callback) => { const sanitizedQuery = sanitizeQuery(query); console.trace("showGameSelector sanitizedQuery", sanitizedQuery); const suggestions = await searchSteamGame(sanitizedQuery, countryCode); const container = document.createElement("div"); const selector = document.createElement("select"); container.appendChild(selector); const emptyOption = document.createElement("option"); emptyOption.disabled = true; emptyOption.selected = true; emptyOption.appendChild(document.createTextNode("—")); selector.appendChild(emptyOption); Object.entries(suggestions).forEach(([appID, name]) => { const option = document.createElement("option"); option.value = appID; option.appendChild(document.createTextNode(name)); selector.appendChild(option); }); selector.addEventListener("input", event => { parent.removeChild(container); if (event.target === null) return; if (!(event.target instanceof HTMLSelectElement)) return; callback(event.target.value); }); parent.appendChild(container); }; const checkGameEntry = async (element, context) => { const gameID = element.getAttribute("data-game-id"); if (gameID === null) return; const gameNameElement = element.querySelector(".game-name"); if (gameNameElement === null) return; const gameName = gameNameElement.textContent?.trim(); if (typeof gameName === "undefined") return; let gameInfo = await context.gameInfo.get(gameID); // game is not on the database, which means either it was added manually // or is a new entry due to an AKS import. In that case, we try to get // the Steam app ID by searching the game name on the Steam Store. if (typeof gameInfo === "undefined") { console.info("game info not found for ID %s, searching Steam...", gameID); const searchResults = await searchSteamGame(gameName, context.countryCode); const searchKeys = Object.keys(searchResults); let steamAppID = undefined; // only assign an app ID if it is an exact match if (searchKeys.length === 1) { steamAppID = searchKeys.shift(); } gameInfo = { id: gameID, steamAppID }; console.trace("updating game info", element); await context.gameInfo.put(gameInfo); } let span = element.querySelector("span.aks-game-state"); if (span !== null) { span.removeAttribute("style"); Array.from(span.children).forEach(span.removeChild); } else { span = document.createElement("span"); span.classList.add("aks-game-state"); } // mark the game as unknown/unlinked due to missing info if (typeof gameInfo.steamAppID === "undefined") { span.style.fontWeight = "bold"; span.style.cursor = "pointer"; span.appendChild(document.createTextNode("⁇")); span.addEventListener("click", () => { showGameSelector(gameNameElement, gameName, context.countryCode, value => { const gameInfo = { id: gameID, steamAppID: value, }; console.trace("updating game", gameInfo); context.gameInfo.put(gameInfo).then(console.trace, console.debug); checkGameEntry(element, context); }); }); gameNameElement?.appendChild(span); return; } if (typeof gameInfo.priority !== "undefined") { element.setAttribute("data-priority", gameInfo.priority.toString()); } // game info found, thus we set the priority attribute, and add the Steam // logo after the name so the user easily knows which games are linked span.classList.add("sprite"); span.style.backgroundPosition = "-44px -22px"; span.style.width = "22px"; span.style.height = "22px"; span.style.transform = "scale(0.5)"; span.style.verticalAlign = "middle"; gameNameElement?.appendChild(span); }; const aksImportSteamWishlist = async (context) => { const listID = document.querySelector(".akswl-list[data-list-id]")?.getAttribute("data-list-id"); if (typeof listID !== "string") { throw new Error("unable to get the current wishlist ID"); } const formData = new URLSearchParams(); formData.set("action", "akswl_import_steam_wishlist"); formData.set("listId", listID); formData.set("steamId", context.steam64ID); formData.set("steamIdType", "steamId64"); const response = await fetch("https://www.allkeyshop.com/blog/wp-admin/admin-ajax.php", { method: "POST", body: formData, }); if (response.status !== 200) { console.error("AKS backend response", response); throw new Error("received non-OK response from AKS backend"); } const data = await response.json(); if (!isAKSWishlistImportSteamResponse(data)) { throw new Error("unknown data format returned by the AKS backend"); } const container = document.querySelector("table.akswl-list tbody"); if (container === null) { throw new Error("wishlist container not found"); } const template = document.createElement("template"); data.data.forEach(entry => { template.innerHTML = entry.trim(); const element = template.content.querySelector("tr.game-row"); if (element === null) return; container.appendChild(element); }); }; const cleanupWishlist = () => { const gameEntries = Array.from(document.querySelectorAll("tr[data-game-id]:not([data-priority])")); gameEntries.forEach(entry => { const deleteElement = entry.querySelector(".delete"); if (deleteElement === null) return; console.info("removing game id %s", entry.getAttribute("data-game-id")); deleteElement.dispatchEvent(new Event("click")); }); }; const tidyDatabase = async (context) => { const databaseKeys = await context.gameInfo.getAllKeys(); if (typeof databaseKeys === "undefined") return; const gameIDs = Array.from(document.querySelectorAll("tr[data-game-id]")).map(element => element.getAttribute("data-game-id")); databaseKeys.filter(key => !gameIDs.includes(key.toString())). forEach(key => { console.info("removing game ID %s from database...", key); context.gameInfo.delete(key); }); }; const importSteamWishlistPriority = async (context) => { const response = await fetchSteamWishlistData(context.steam64ID); const content = response.response; const data = JSON.parse(content); if (!isSteamWishlistData(data)) { console.error("invalid data returned by the Steam API", data); return; } Object.keys(data).forEach(async (steamAppID) => { const steamGameData = data[steamAppID]; if (typeof steamGameData === "undefined") return; console.trace("retrieving AKS game ID for steam app ID %s", steamAppID); const key = await context.gameInfo.getKeyByIndex(steamAppID, "steamAppID"); if (typeof key === "undefined") { console.warn("no game info found for '%s'", steamGameData.name); return; } console.trace("retrieving game info for game ID %s, steam app ID %s", key, steamAppID); const gameInfo = await context.gameInfo.get(key); if (typeof gameInfo === "undefined") { console.warn("no game info found for '%s'", steamGameData.name); return; } gameInfo.priority = steamGameData.priority; console.trace("updating game info", gameInfo); await context.gameInfo.put(gameInfo); }); // sort elements with priority const listContainer = document.querySelector("table.akswl-list tbody"); if (listContainer === null) return; const prioritizedElements = Array.from(document.querySelectorAll("tr[data-game-id][data-priority]")).sort((a, b) => { const valueA = a.getAttribute("data-priority"); const valueB = b.getAttribute("data-priority"); if (valueA === null || valueB === null) return 0; return parseInt(valueA) - parseInt(valueB); }); // reverse the list and "unshift" on the parent. DOM insert/append methods // move elements, so this effectively orders the synced entries, while // all other entries will be pushed to the end of the list. prioritizedElements.reverse().forEach(element => { listContainer.insertBefore(element, listContainer.firstChild); }); const saveOrderButton = document.querySelector("button.save-order"); if (saveOrderButton === null) return; console.info("saving order..."); saveOrderButton.disabled = false; saveOrderButton.dispatchEvent(new Event("click")); }; const augmentWishlistGameRows = (context) => { const gameRows = document.querySelectorAll(".akswl-list tbody tr.game-row"); gameRows.forEach(gameRow => checkGameEntry(gameRow, context)); }; class StoreManager { databaseName; version; storeName; storeParameters; db; openHandler = (db) => { this.db = db; }; upgradeHandler = (db) => { db.createObjectStore(this.storeName, this.storeParameters); }; constructor(storeName, databaseName, version, storeParameters) { this.storeName = storeName; this.databaseName = databaseName; this.version = version; this.storeParameters = storeParameters ?? { autoIncrement: false, }; } open = async () => new Promise((resolve, reject) => { if (typeof this.db !== "undefined") reject("database is already opened"); const request = indexedDB.open(this.databaseName, this.version); request.onerror = reject; request.onsuccess = dbEventCallback(db => { this.openHandler(db); resolve(); }, reject); request.onupgradeneeded = dbEventCallback(this.upgradeHandler, reject); }); add = (value, key) => new Promise((resolve, reject) => { if (typeof this.db === "undefined") { reject("db is not initialized"); return; } const transaction = this.db.transaction(this.storeName, "readwrite"); transaction.onerror = reject; transaction.oncomplete = dbEventCallback(console.debug, reject); const store = transaction.objectStore(this.storeName); const request = store.add(value, key); request.onerror = reject; request.onsuccess = dbEventCallback(resolve, reject); }); get = async (query) => new Promise((resolve, reject) => { if (typeof this.db === "undefined") { reject("db is not initialized"); return; } const transaction = this.db.transaction(this.storeName, "readonly"); transaction.onerror = reject; transaction.oncomplete = dbEventCallback(console.debug, reject); const store = transaction.objectStore(this.storeName); const request = store.get(query); request.onerror = reject; request.onsuccess = (event) => { if (event.target === null) { reject(event); return; } resolve(event.target.result); }; }); delete = async (key) => new Promise((resolve, reject) => { if (typeof this.db === "undefined") { reject("db is not initialized"); return; } const transaction = this.db.transaction(this.storeName, "readwrite"); transaction.onerror = reject; transaction.oncomplete = dbEventCallback(console.debug, reject); const store = transaction.objectStore(this.storeName); const request = store.delete(key); request.onerror = reject; request.onsuccess = dbEventCallback(resolve, reject); }); put = async (data, key) => new Promise((resolve, reject) => { if (typeof this.db === "undefined") return; const transaction = this.db.transaction(this.storeName, "readwrite"); transaction.onerror = console.debug; transaction.oncomplete = dbEventCallback(console.debug, reject); const store = transaction.objectStore(this.storeName); const request = store.put(data, key); request.onerror = reject; request.onsuccess = dbEventCallback(resolve, reject); }); getAllKeys = async () => new Promise((resolve, reject) => { if (typeof this.db === "undefined") { reject("db is not initialized"); return; } const transaction = this.db.transaction(this.storeName, "readonly"); transaction.onerror = console.trace; transaction.oncomplete = dbEventCallback(console.debug, reject); const store = transaction.objectStore(this.storeName); const request = store.getAllKeys(); request.onerror = reject; request.onsuccess = dbEventCallback(resolve, reject); }); getKeyByIndex = async (query, indexName) => new Promise((resolve, reject) => { if (typeof this.db === "undefined") { Promise.reject("db is not initialized"); return; } const transaction = this.db.transaction(this.storeName, "readonly"); transaction.onerror = console.trace; transaction.oncomplete = dbEventCallback(console.debug, reject); const store = transaction.objectStore(this.storeName); if (!store.indexNames.contains(indexName)) { reject("index is not defined"); return; } const index = store.index(indexName); const request = index.getKey(query); request.onerror = reject; request.onsuccess = dbEventCallback(resolve, reject); }); } class GameInfoStore extends StoreManager { constructor() { super("appid", "steam", 1, { autoIncrement: false, keyPath: "id", }); } upgradeHandler = (db) => { console.info("creating store...", this.storeName); const store = db.createObjectStore(this.storeName, this.storeParameters); console.info("creating index...", "steamAppID"); store.createIndex("steamAppID", "steamAppID", { unique: true }); }; } class UserScript { startupCallbacks; hrefListeners; context; constructor(context) { this.startupCallbacks = []; this.hrefListeners = {}; this.context = context; } addStartupCallback = (callback) => this.startupCallbacks.push(callback); addPageListener = (href, listener) => { let hrefListener = this.hrefListeners[href]; if (typeof hrefListener !== "undefined") return; this.hrefListeners[href] = listener; }; start = async () => { // executes all startup callbacks prior to running a page listener await Promise.allSettled(this.startupCallbacks.map(callback => callback(this.context))); // search for listeners applicable to the current page const listeners = Object.keys(this.hrefListeners). filter(href => location.href.match(href)). map(href => this.hrefListeners[href]). filter((listener) => typeof listener === "function"); // execute the listeners listeners.forEach(listener => listener(this.context)); }; } const instance = new UserScript({ gameInfo: new GameInfoStore(), countryCode: "", steam64ID: "", }); instance.addStartupCallback(async (context) => { await context.gameInfo.open(); }); instance.addStartupCallback(async (context) => { context.countryCode = await getCountryCode(); }); instance.addStartupCallback(async (context) => { context.steam64ID = await getSteam64ID(); }); instance.addPageListener("https://www.allkeyshop.com/blog/list/.+/.+/", async (context) => { // imports the steam wishlist data and reorders the games using the priority GM_registerMenuCommand("Import Steam Wishlist", async () => { // import steam wishlist games await aksImportSteamWishlist(context); // remove unknown game entries cleanupWishlist(); // import the priority await importSteamWishlistPriority(context); // adds icons on the game rows to indicate the game state augmentWishlistGameRows(context); }, "i"); GM_registerMenuCommand("Reorder Wishlist", async () => { // import the priority await importSteamWishlistPriority(context); // adds icons on the game rows to indicate the game state augmentWishlistGameRows(context); }, "r"); GM_registerMenuCommand("Tidy Database", () => tidyDatabase(context), "t"); GM_registerMenuCommand("Cleanup list", cleanupWishlist, "c"); const steamIDInputElement = document.querySelector("input.steam-id-input"); if (steamIDInputElement !== null) { steamIDInputElement.value = context.steam64ID; steamIDInputElement.dispatchEvent(new Event("input", { bubbles: true, cancelable: true, })); } augmentWishlistGameRows(context); }); instance.start(); })();