Exceptional
Context
I have been writing a userscript to add a description of a game to their entries on the Humble Bundle library page. Part 1 was a proof of concept, and in Part 2 we made important parts of the userscript aysnchronous.
Shimmy
So even since a while ago we have had a userscript that works well enough for my own use. But I’d like to release this so that others can benefit, so we’d better tidy things up a bit. For example, glancing at the code I can see a bunch of weird, shim-like hacks to make one part work with another:
// async update on-page console.log("Updating page"); var gameDetails = getGameDetails(gameInfo); gameDetails.then(details => { let gD = {}; gD.gameDescription = details; updateGameDetailsOnPage(gD); });
Here we have a method (getGameDetails()
) which is returning things in a different format from what the final consumer (updateGameDetailsOnPage()
) uses.
: shim in the physical sense of ‘this doesn’t quite fit or isn’t quite the right size, so I’ll temporarily use a thing to make it work’. Of course, in both the physical and digital analogy, there’s often nothing qutie so permanent as a ‘temporary’ fix.
Raising Errors
Since we’ve wrapped everything up in Promises last time around, we can now use their approach to exceptions. If we use the .then()
/ .catch()
approach, we can throw errors and have them caught.
For example, at present we have nothing handling cases where an appid lookup fails/misses:
/** * get a Steam AppID for querying Steam public API - exact matches only * @param {String} gameTitle title for appid lookup (exact match) * @returns {String} appid of game in steam API */ async function steamGetAppIDFromTitle ( gameTitle ) { function getIDByTitle( gameTitle ) { return GM_getValue("_steamappids").filter( function(app){ return app.name == gameTitle; } ); } appid = getIDByTitle(gameTitle); if (appid.length) { appidnice = appid[0].appid; console.log(`Found app ID: ${appidnice}`); return appidnice; } }
Even my linter (ts-ls
) warns me that this won’t always return a value! We could throw an error here, or we could wrap this up in another Promise. Let’s do the latter:
/** * get a Steam AppID for querying Steam public API - exact matches only * @param {String} gameTitle title for appid lookup (exact match) * @returns {String} appid of game in steam API */ async function steamGetAppIDFromTitle ( gameTitle ) { function getIDByTitle( gameTitle ) { return GM_getValue("_steamappids").filter( function(app){ return app.name == gameTitle; } ); } let appidPromise = new Promise((resolve, reject) => { console.log(`looking up appid for ${gameTitle}`); appid = getIDByTitle(gameTitle); if (appid.length) { let appidnice = appid[0].appid; console.log(`Found app ID: ${appidnice}`); resolve(appidnice); } else { reject(`appid not found for ${gameTitle}`); } }); return await appidPromise; }
As the Promise is reject
-ed if an appid is not found, this process now halts the process of querying the Steam web API if an appid is not found.
Issues / Questions Arising During Development
Why do I get “‘await’ has no effect on the type of this expression”?
Thanks to this SO QA, I realised I had the wrong variable hint in my JSDoc:
> For example I had an async function that had @returns {string}:
Whoops!
If a game is delisted, it will have an appid but no entry
Galcon Legends https://delistedgames.com/galcon-legends/ This threw me off at first!
Wrapping a call stack in a .catch() doesn’t magically catch every error
I found that out when a bottom level synchronous function threw an error that made it to the console as an ‘unhandled Error (in promise)’, rather being nicely reported to the end user. Wrapping that call in a try..catch
and reject()
-ing the enclosing Promise with the raised error sorted that out.
Gib Teh Codes
Once again, until this is formally released, I would caution against its use.
// ==UserScript== // @name Humble Game Info // @version 0.2.1 // @description Fetch game info for games in Humble Bundle library // @author bertiebaggio // @require file:///home/robert/code/humblegameinfo/HumbleGameInfo.user.js // @require https://ajax.googleapis.com/ajax/libs/jquery/3.7.0/jquery.min.js // @require https://gist.github.com/raw/2625891/waitForKeyElements.js // @require https://cdn.jsdelivr.net/npm/@violentmonkey/shortcut@1 // @match https://www.humblebundle.com/home/library* // @run-at document-idle // @grant GM_xmlhttpRequest // @grant GM_listValues // @grant GM_getValue // @grant GM_setValue // @grant GM_deleteValue // ==/UserScript== /** * Purpose: to add a short game description to Humble Store library page * * Game description is pulled from the unauthenticated Steam web API if game title has a corresponding appID. * * External libs: * - jQuery * - waitForKeyElements (for games list loading in) * - @violentmonkey/shortcut (for debug hotkeys) **/ // if you prefer, save the JSON from the URL below and use file URI, eg: // const steamAppidURL = "file:///home/user/downloads/steamappids.json"; const steamAppidURL = "https://api.steampowered.com/ISteamApps/GetAppList/v0002/"; const dummyGameInfo = { gameTitle: "Placeholder Game Title", gamePublisher: "Placeholder Game Publisher" }; const dummyGameDetails = { gameDescription: "This is a placeholder description. If you're seeing this it could mean: a request is in progress, you're testing, or something has gone wrong.", gameTags: ["FPS", "Open World"], }; const gameDetailsContainer = "<div id='gamedetails' class='gameinfo' style='margin-top: 1em;'><p>Game info goes here!</p></div>"; function clearAllCache ( ) { for (const gmkey of GM_listValues()) { console.log(`deleting ${gmkey}`); GM_deleteValue( gmkey ); } } function listAllCache ( ) { for (const gmkey of GM_listValues()) { console.log(`${gmkey}: ${GM_getValue( gmkey )}`); } } function listSteamCache() { let _steamappids = GM_getValue("_steamappids", -1); if (_steamappids == -1 || _steamappids == 'undefined') { console.log("No Steam cache found"); } else { console.log(JSON.stringify(_steamappids)); } } function listGameCache() { let hgiGames = GM_getValue("hgiGames", -1); if (hgiGames == -1 || hgiGames == 'undefined') { console.log("No game cache found"); } else { console.log(JSON.stringify(hgiGames)); } } async function makeJSONRequest( uri ) { return new Promise((resolve, reject) => { GM_xmlhttpRequest({ method: "GET", url: uri, anonymous: true, responseType: 'json', onload: response => resolve(response), onerror: error => reject(error), }); }); } /** * Add game description to page on RHS */ function addGameDescription ( ) { // TODO- rewrite this or rename it to be more descriptive // Ensure we have somewhere to put the game into by checking for presence of RHS being open detailsView = $("div.details-view"); if (detailsView.length == 1) { detailsView.append(gameDetailsContainer); } else { console.log(".details-view <div> not found! Will not look up game"); } let queryDetails = getGameInfoFromPage(); // set a placeholder description updateGameDetailsOnPage(dummyGameDetails); // async update on-page console.log("Updating page"); let gameDetails = getGameDetails(queryDetails); gameDetails .then(details => { updateGameDetailsOnPage(details); }) .catch(error => { console.log(`Error while fetching game details: ${error}`); let errorDetails = {errorDescription: error}; updateGameDetailsOnPage(errorDetails); }); } /** * Add game description to the visible #gamedetails div * @param {Object} gameDetails Object with .gameDescription property (or .errorDescription) */ function updateGameDetailsOnPage ( gameDetails ) { let gameInfoNode = $("div#gamedetails"); gameInfoNode.find("p").remove(); // take away placeholder text gameInfoNode.find("h3").remove(); // take away placeholder heading gameInfoNode.append("<h3><i class='hb hb-info'></i>Description</h3>"); if ("gameDescription" in gameDetails) { gameInfoNode.append(`<p>${gameDetails.gameDescription}</p>`); } else if ("errorDescription" in gameDetails) { gameInfoNode.append(`<p>Error during lookup: ${gameDetails.errorDescription}</p>`); } else { gameInfoNode.append(`<p>Something went wrong during lookup, we have neither a game nor error description!</p>`); } } /** * Extract game details (ie description and genres) from response object * @param {Object} response Object returned from XHR * @returns {Object} {gameDescription: String, gameGenres: Array} TODO check latter */ function steamGetGameDetailsFromResponse ( response ) { console.log(`Got response from steamapi`); // appid = response.context.appid; // needed as json object is returned under appid.data // NOTE: technically it's not needed as we could extract the appid from response.finalUrl let appid = response.finalUrl.split("=", 2)[1]; // added as games can have an appid entry but no data // eg Galcon Fusion and Legends (https://delistedgames.com/galcon-legends/) if (!("data" in response.response[appid])){ throw new Error(`No game data for appid ${appid}`); } let appiddata = response.response[appid].data; if ("short_description" in appiddata) { var description = appiddata.short_description; } else { console.log(JSON.stringify(appiddata)); throw new Error("No short description in data!"); } if ("genres" in appiddata) { var genres = appiddata.genres; } else { var genres = []; } let gameDetails = { "gameDescription": description, "gameGenres": genres }; return gameDetails; } async function steamFetchGameByAppID ( appid ) { let baseURL = "https://store.steampowered.com/api/appdetails?appids="; let requestURL = baseURL.concat(appid); let gamePromise = new Promise( async function(resolve, reject) { console.log(`Fetching from ${requestURL}`); let response = await makeJSONRequest(requestURL).catch(error => reject(error)); if (response) { try { let gameDetails = await steamGetGameDetailsFromResponse(response); if (gameDetails) { resolve(gameDetails); } else { reject("No game details"); } } catch (error) { reject(error); } } }); return await gamePromise; } /** * Store list of {appid: name} pairs in localstorage * ie _steamappids = { [ {12345: "Example Game"}, {67890: "Another Game"} ] }; * @param {Object} response response object from XHR */ async function steamCacheAppIDsFromResponse ( response ) { console.log("Caching appids"); let appidstocache = new Array(); for (const appentry of response.response.applist.apps) { // TODO: this could probably be done more efficiently with filter (?) if (appentry.name.length && appentry.appid) { appidstocache.push(appentry); } } console.log(`Storing ${appidstocache.length} appids`); await GM_setValue("_steamappids", appidstocache); } /** * get a Steam AppID for querying Steam public API - exact matches only * @param {String} gameTitle title for appid lookup (exact match) * @returns {Promise<String>} appid of game in steam API */ async function steamGetAppIDFromTitle ( gameTitle ) { const getIDByTitle = async ( gameTitle ) => { return await GM_getValue("_steamappids").filter( function(app){ return app.name == gameTitle; } ); }; let appidPromise = new Promise( async (resolve, reject) => { console.log(`looking up appid for ${gameTitle}`); appid = await getIDByTitle(gameTitle); if (appid.length) { let appidnice = appid[0].appid; console.log(`Found app ID: ${appidnice}`); resolve(appidnice); } else { reject(`appid not found for ${gameTitle}`); } }); return await appidPromise; } /** * Pull steam appids and hands off to steamCacheAppIDsFromResponse() to cache locally * @returns {Promise<String>} "done" if done */ async function steamFetchAppIDs () { let appidPromise = new Promise(async function(resolve, reject) { let response = await makeJSONRequest(steamAppidURL); if (response) { steamCacheAppIDsFromResponse(response); resolve("done"); } else { reject(error); } }); return await appidPromise; } /** * Check steam appid cache and populate if needed (TODO time-based updates) */ async function steamEnsureAppidsPopulated ( ) { let populatedPromise = new Promise( async (resolve, reject) => { let _steamappids = await GM_getValue("_steamappids", -1); if (_steamappids == -1 || _steamappids == 'undefined') { console.log(`Populating steam appid cache...`); isdone = await steamFetchAppIDs(); if (isdone) { console.log(`Cache update is: ${isdone}`); resolve(); } else { reject(error); } } else { // Cache already populated resolve(); } }); return populatedPromise; } /** * Wrapper function for fetching game details from steam * @return {Promise<Object>} Game info from Steam API * (eg {"gameDescription": "Hack your way through brain scrambling puzzles while maneuvering through a thumb cramping maze of enemies."}) */ async function steamFetchGameDetailsByTitle ( gameTitle ) { // Ensure _steamappids are populated for lookup await steamEnsureAppidsPopulated(); console.log("Pulling game info..."); console.log(`Getting appid from gameTitle (${gameTitle}) then fetching by appid...`); let appid = await steamGetAppIDFromTitle(gameTitle); return steamFetchGameByAppID(appid); // return await steamEnsureAppidsPopulated() // .then( () => steamGetAppIDFromTitle(gameTitle)) // .then(steamappid => steamFetchGameByAppID(steamappid)); } /** * Based on supplied info, return or store-and return game info * @param {Object} queryDetails Query based on this object's .gameTitle * @return {Object} Contains game details like .gameDescription .gameTags */ async function getGameDetails ( queryDetails ) { let queryTitle = queryDetails.gameTitle; console.log(`Fetching game info for ${queryTitle}`); console.log(`Checking cache...`); let hgiGames = await GM_getValue("hgiGames", -1); if (hgiGames == -1 || hgiGames == 'undefined') { console.log("No game data structure in cache. Creating..."); hgiGames = {}; } if (queryTitle in hgiGames) { return hgiGames[queryTitle]; } else { console.log("Cache miss! Updating..."); let gameInfo = await steamFetchGameDetailsByTitle(queryTitle); hgiGames[queryTitle] = gameInfo; GM_setValue("hgiGames", hgiGames); } return hgiGames[queryTitle]; } /** * Get game title and publisher from page * Note that we don't actually use publisher at present with steam API * @return {Object} { gameTitle: title, gamePublisher: publisher } */ function getGameInfoFromPage ( ) { gameTitleNode = $("div.details-heading").find(".text-holder").find("h2"); if (gameTitleNode.length) { gameTitle = gameTitleNode.text(); } gamePublisherNode = $("div.details-heading").find(".text-holder").find("p"); if (gamePublisherNode.length) { gamePublisher = gamePublisherNode.text(); } return { gameTitle: gameTitle, gamePublisher: gamePublisher}; } waitForKeyElements("div.subproduct-selector", function( jNode ){ // Main entry point addGameDescription - adds listener to game entry divs on LHS jNode.on("click", { node: jNode }, addGameDescription); }); // utility stuff $(window).on("load", () => { console.log("HGI: Setting up key bindings"); VM.shortcut.register('c-; c-c', () => { console.log("Clearing cache..."); clearAllCache(); }); VM.shortcut.register('c-; c-a', () => { console.log("Listing all cache..."); listAllCache(); }); VM.shortcut.register('c-; c-s', () => { console.log("Listing steam cache..."); listSteamCache(); }); VM.shortcut.register('c-; c-g', () => { console.log("Listing game cache..."); listGameCache(); }); });
Future Steps
Even after sorting that out and adding error-handling (see notes below) there’s still more that can be done:
- we have a bug / flaw in our approach insofar as the API request is asynchronous (as it should be) but the page can change in the interim. If a user clicks one game then another quickly, the request for the first game’s details me resolve after the second, showing the first game’s details as the description for the second.
- we have yet to do anything with the genres the steam API can return
- we have no automated tests (!!)
- fuzzy/partial matching on game titles might be nice (there’s even a library for that: Steam-App-ID-finder, though it might be overkill)