These look interesting, tell me more
- Background
- Getting Started with Userscript Development
- Gib Me Teh Codes
- Outcome
- Next Steps
- Issues / Questions Arising During Development
- What userscript platform?
- Why doesn’t Violentmonkey appear in my toolbar?
- Can I use a local file for quicker develop/test cycles?
- Can I use jQuery?
- How do I bind on a ‘click’ event?
- How do I add something to the page? And remove something?
- Stuff isn’t on the page yet!
- How do I read a local file? (and: “GM_xmlhttpRequest is not defined”)
- Passing context through requests
- How do I find a match in a big object?
- How do I use a numerical key in JSON?
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).”
: World of Goo, Aquaria, Gish, Lugaru HD, Punumbra: Overture and Samorost 2
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:
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; } ); }
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}`);
Pingback: Humble Game Info Userscript: async, await and Promises (Part 2) – Rob's Blog
Pingback: Humble Game Info Userscript: Handling Exceptions and Improving Consistency (Part 3) – Rob's Blog