Skip to content

Humble Game Info Userscript: Concept and Starting From Scratch

These look interesting, tell me more

Background

Way back in the mists of time in 2010 something cool happened. A new kid on the block came along and said “Hey, here’s six games† you can buy for whatever price you nominate, and you can have them DRM-free on Linux, Mac and Windows. Not only that, you can choose how you divide the price you pay between developers and two charities (EFF and Child’s Play).”

The first Humble Bundle was well-received, and more Humble Bundles followed. After a few purchased for particular games, I’ve ended up with a library of games where I don’t entirely recall what the games are.

As annoyances go it’s relatively minor, but that’s no reason not to improve things! Userscripts do exactly this- make minor (or not-so-minor) changes to web pages/websites to improve how they function. It’s been a while since I looked at javascript/typescript and even longer since I looked at userscript development (if ever?), so I’ll be starting from scratch.

Getting Started with Userscript Development

Preface, or “On Starting to Start”

As a pre-script, I want to share a short reflection. Oftentimes I find that starting on these kinds of things poses a bit of difficulty- I find it’s easy to get caught up at the ‘blank page’ stage, caught between wanting to do something well and Right™ but lacking the requisite knowledge and plan. It can become a kind of inertia that can delay actually starting. I’ve found that the key to overcoming this is to do something that isn’t too quick-and-dirty, but that also isn’t over-engineered either. If I had to gravitate to one end of the spectrum, I’d lean to the former at this point, with the understanding that anything written will be junked and we start over once we know roughly what we’re doing.

In other words- don’t get so caught up trying to adhere to the best practices and style guides of a language that you are completely unfamiliar with that you don’t actually write a single line.

Concept

Humble Game Info: A userscript that shows a brief description of games in your Humble Bundle library

Useful Resources

I stumbled across a very useful thread on userscript development as a newcomer on a forum for a platform for learning kanji, of all places:

Best practices for testing userscripts?

Thanks to Rrwrex for taking the time to write all that up.

High-Level Overview

In the Humble Bundle ‘Library’ section, there is a list of games. We should show a description of a particular game by pulling that in from a database and displaying that somewhere on the page.

There’s ample room to insert the description below the ‘Download’ and ‘Steam’ sections.

For the information source, there’s a few to choose from: Mobygames has an API, but with relatively aggressive limits (no more than 1 request per second or 360 per hour). TheGamesDB also has an API that requires a key which needs to be requested manually. Also IGDB and RAWG.

Thanks to Bob for suggesting the Steam web API, which to my surprise doesn’t seem to require an API key to read from.

Gib Me Teh Codes

Please don’t use this, it’s a local dev version where I have the steam appid list saved as a local JSON file (buy my book, ‘Implementing Caching for Complete Duffers’ today!)

// ==UserScript==
// @name Humble Game Info
// @version 0.0.4
// @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
// @match https://www.humblebundle.com/home/library*
// @run-at document-idle
// @grant GM_xmlhttpRequest
// ==/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.
  */
var dummyGameInfo = { gameTitle: "Placeholder Game Title",
                      gamePublisher: "Placeholder Game Publisher"
                    };

var dummyGameDetails = { gameDescription: "This is a placeholder description. If you're seeing this you're either testing or something has gone wrong",
                         gameTags: ["FPS", "Open World"],
                       };

var gameDetailsContainer = "<div id='gamedetails' class='gameinfo' style='margin-top: 1em;'><p>Game info goes here!</p></div>";

function getGameDescription ( ) {
    // 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);
    }
    gameInfo = getGameInfoFromPage();
    console.log(gameInfo);

    var gameDetails = getGameDetails(gameInfo);
    updateGameDetailsOnPage(gameDetails); // TODO update this to make it obvious this is placeholder
}

function updateGameDetailsOnPage ( gameDetails ) {
    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>");
    gameInfoNode.append(`<p>${gameDetails.gameDescription}</p>`);
}

function steamGetGameDescriptionFromResponse ( response ) {
    console.log(`Got response from steamapi`);
    appid = response.context.appid;  // needed as json object is returned under appid.data

    gameDetails = {
        gameDescription: response.response[appid].data.short_description,
        gameGenres: response.response[appid].data.genres,
    };

    console.log(`Updating game details!`);
    updateGameDetailsOnPage(gameDetails);
}

function steamFetchGameByAppID ( appid ) {
    baseURL = "https://store.steampowered.com/api/appdetails?appids=";
    requestURL = baseURL.concat(appid);
    requestDetails = {
        context: { appid: appid },
        url: requestURL,
        anonymous: true,
        responseType: 'json',
        onload: steamGetGameDescriptionFromResponse,
    };
    console.log(`Fetching from ${requestURL}`);
    GM_xmlhttpRequest(requestDetails);
}

function steamGetAppIDFromResponse ( response ) {
    function getIDByTitle( gameTitle, appids ) {
        return appids.applist.apps.filter(
            function(app){ return app.name == gameTitle; }
        );
    }
    appids = response.response;
    gameTitle = response.context._gameInfo.gameTitle;

    appid = getIDByTitle(gameTitle, appids);
    if (appid.length) {
        appidnice = appid[0].appid;
        console.log(`Found app ID: ${appidnice}`);
        steamFetchGameByAppID(appidnice);
    }

}

function steamFetchAppIDs ( gameInfo ) {
    /* get a Steam AppID for querying Steam public API
       */
    URI = "file:///home/robert/downloads/steamappids2.json";
    requestDetails = {
        url: URI,
        responseType:  'json',
        anonymous: true,
        context: {_gameInfo: gameInfo},
        onload: steamGetAppIDFromResponse,
    };
    GM_xmlhttpRequest(requestDetails);
}

function getGameDetails ( gameInfo ) {
    // TODO: implement caching
    console.log(`Fetching game info for ${gameInfo.gameTitle}`);
    steamFetchAppIDs(gameInfo);
    var gameDetails = dummyGameDetails; // placeholder until request to steam completes
    return gameDetails;
}

function getGameInfoFromPage() {
    /* return an object with game name and publisher */
    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 - adds listener to game entry divs on LHS
    jNode.on("click", { node: jNode }, getGameDescription);
});

Outcome

It works! When we click a game on the left hand side list, the details are loaded on the right.

We borrowed the information icon by using an <i> element with the classes hb hb-info, which I guessed from looking at other elements on the page.

Next Steps

Now that we know the proof of concept works, we need to implement some actual caching of the appids and of the game info.

Issues / Questions Arising During Development

What userscript platform?

Greasemonkey / Tampermonkey / Violentmonkey? I went with Violentmonkey as it seemed to offer the best combination of being cross browser etc.

There is a small guide for developing a userscript on their site, and a relatively terse API too.

Why doesn’t Violentmonkey appear in my toolbar?

I’m… not sure.

Can I use a local file for quicker develop/test cycles?

Yes! Though I ran into an issue with that which really slowed me down.

I followed the procedure outlined in this SO answer, which is to @require the script. It worked fine for a while: saving the file in emacs and reloading the page would run the updated script.

However, around the time I was testing waitForKeyElements (see ‘Stuff isn’t on the page yet!’) I somehow ended up with a desync between what was on disk, what ViolentMonkey thought and what was being run in the page context- for the latter, an older version was being used.

I got thinks back into alignment by disabling ViolentMonkey an re-enabling it, as well as re-installing the userscript.

Now my process for the script is to look at the userscripts ViolentMonkey page, which should indicate it has been updated right after it is saved to disk. I then refresh that page, so I can then confirm re-installation with ctrl-enter, and finally I refresh the page the userscript runs on. It’s not quite as automated as ‘save file, auto refresh page’, but it’s a happy enough compromise that doesn’t use too much time.

Can I use jQuery?

Of course, you can simply include it in the userscript declaration. Check for the latest version on the Google hosted libraries, or you can use jQuery’s own CDN:

// @require https://ajax.googleapis.com/ajax/libs/jquery/3.7.0/jquery.min.js
// or:
// @require https://code.jquery.com/jquery-3.7.0.min.js

How do I bind on a ‘click’ event?

The standard $().on('click', handler) works well (see docs).

And remember: friends don’t let friends use onclick!

How do I add something to the page? And remove something?

The .append() method (as in this SO QA) works fine. I ended up finding the node I wanted to add to, which was a div with class “details-view”, made sure it was there by checking .length and then appended there:

detailsView = $("div.details-view");
if (detailsView.length == 1) {
    detailsView.append("<div id='gamedetails' class='gameinfo' style='margin-top: 1em;'><p>Game info goes here!</p></div>");
}

I then later .remove() that placeholder once I have the actual info:

gameInfoNode = $("div#gamedetails");
gameInfoNode.find("p").remove();  // take away placeholder text
gameInfoNode.find("h3").remove();  // take away placeholder heading

Stuff isn’t on the page yet!

This tripped me up for a little while. The ‘Library’ page loads and ‘finishes’ while still empty, and asynchronously pulls in the user’s library. Neither $(document).ready() nor $(window).load() nor @run-at document-idle worked, I had to use a third party script:

waitForKeyElements

It gets included in the userscript header:

// @require  https://gist.github.com/raw/2625891/waitForKeyElements.js

How do I read a local file? (and: “GM_xmlhttpRequest is not defined”)

You can make an AJAX / xmlhttpRequest to a <file:///> URI:

// ==UserScript==
// @grant GM_xmlhttpRequest
// ==/UserScript==

  function readLocalJSONFile ( gameInfo ) {
  URI = "file:///home/robert/downloads/steamappids2.json";
  requestDetails = {
      url: URI,
      responseType:  'json',
      anonymous: true,
      context: {_gameInfo: gameInfo},
      onload: steamGetAppIDFromResponse,
  };
  // console.log(`requestDetails._gameInfo: ${requestDetails._gameInfo}`);
  let control = GM_xmlhttpRequest(requestDetails); // there is also GM.xmlHttpRequest
      }

See the API docs for more info.

Note that you have to explicitly @grant GM_xmlhttpRequest or you will get an error about an undefined reference: “GM_xmlhttpRequest is not defined” (sandboxing!).

Passing context through requests

GM_xmlhttpRequest’s details object has the context object, which can be used to pass data through to the callback.

How do I find a match in a big object?

Since the steam web interface supplies nearly 10MB of JSON with their games listed by appid, we need a way to select the matching one. This QA on filtering arrays on SO put me in the right direction.

Based on that, the following works but might not be optimal:

function steamGetAppIDFromResponse ( response ) {
    function getIDByTitle( gameTitle, appids ) {
        return appids.applist.apps.filter(
            function(app){ return app.name == gameTitle; }
        );
    }

filter() docs

Note that what is returned isn’t the appid, it’s the object which matches the appid (which includes the game title), so I have to select that part of the object:

let appid = filtered_object[0].appid;

How do I use a numerical key in JSON?

Well, you don’t, you use a string representation of a number. Unfortuantely that doesn’t work in javascript dot notation, instead you can use array notation:

let jin = { "12345678": { "title": "Basil The Great Mouse Detective"} };

console.log(`Title from 'jin' is: ${jin[12345678].title}`);

Tell us what's on your mind

Discover more from Rob's Blog

Subscribe now to keep reading and get access to the full archive.

Continue reading