Creating a most popular posts page with a custom ghost helper to download Google Analytics data

Akos Nagy
Oct 15, 2018

TL;DR I have created a custom Handlebars helper for Ghost to download the most viewed posts from Google Analytics. If you are only interested in using the helper, check out the Github repo and its wiki. The blog post below goes into a bit more detail on my motivation and the technical implementation.

Displaying the mos popular posts on the blog

In one of my previous posts I described the new features that I have added to this blog. I also told you to stay tuned, because new features were coming :)

I often see blogs with 'Greatest Hits' or 'Most popular posts' sections. This is a good feature and helps people who arrive at the blog just browsing (or want to stay on the blog after finding the specific information they need). I often find such question on Stackoverflow (like this or this and sometimes similary questions come up on the Ghost forum as well (like this one. But for this to be really useful, the feature has to be implemented so that it downloads data from an analytics service like Google Analytics dynamically and the list of most popular posts are not maintained manually.

Unfortunately Ghost doesn't integrate with Google Analytics natively, or any other analytics service, for that matter. But this is actually not a problem; it gives us tinkerers an ooportunity to try to add it to the platform.

Adding Google Analytics to track views

I'm not going to go into the details of doing this; just do a Google search and there are plenty of resources on how to do this. But this is only one half (the easier half) of the solution. After the views are tracked, somehow you have to download and display the most viewed pages as well.

Designing the feature

Based on my previous post, the best option to implement this is probably a custom helper. I imagined a block helper for this feature that works like this:

{{#top viewId = '12345678' interval = 'thisWeek' maxItems=10}}
  <a href="{{@blog.url}}{{postUrl}}">{{postTitle}} ({{viewCount}})</a>	    
{{/top}}

The interval parameter allows for specifying the values 'thisWeek', 'allTime', 'thisMonth' or 'thisYear' each downloading analytics data for the respected periods. The maxItems parameter allows for specifying the number of records to be downloaded. I ended up adding a new parameter, viewId, which is something related to the Google Analytics API (for more details on the View ID and how to get it, check out the wiki of the helper in the Github repo). In the inner template, you can use the postUrl, the postTitle and the viewCount properties as placeholders.

OK, so based on my previous post in the topic, first I created a file at the right place named top.js and added a little piece of code to it, just enough to support the parameters that I need from the specification above:

module.exports = function top(options) {
}

The next thing to do was to check for the parameters and add default values. Again, I have no idea how good or 'best-practice' this is, I just saw it in other helpers (seems jibberish to me, so this is a good sign of it being actual Javascript- practice):


module.exports = function top(options) {  	
  options = options || {};
  options.hash = options.hash || {};	        
  var maxItems = parseInt(options.hash.maxItems,10) || 10;
  var interval=options.hash.interval || 'allTime';
  var viewId = options.hash.viewId;
  if (!viewId || !viewId.length)
      return Promise.resolve('No viewId specified');
}

Next, the interval parameter has to be to an actual date in the format YYYY-MM-DD. As far as I know (and I did google it), there is no built-in way of handling dates in Node.js, so you have to implement it yourself or use an external library. Instead of googling some more for the usually used library, I decided I could do this myself (note the padLeft function which I also wrote myself, but source code is omitted here; check out the Github repo to see the function's code). It's ugly, but it works (and it looks like actual Javascript code):

module.exports = function top(options) {  	
  options = options || {};
  options.hash = options.hash || {};	        
  var maxItems = parseInt(options.hash.maxItems,10) || 10;
  var interval=options.hash.interval || 'allTime';	
  var viewId = options.hash.viewId;
  if (!viewId || !viewId.length)
      return Promise.resolve('No viewId specified');
  
  var currentDate = new Date();
    if (interval=='thisYear') {
		var startDate=currentDate.getFullYear()+'-01-01';
	} else if (interval=='thisMonth') {
		var currentMonth = currentDate.getMonth()+1;
		var startDate=currentDate.getFullYear()+'-'+padLeft(currentMonth,2)+'-01';
	} else if (interval=='thisWeek') {		
		var currentDay = currentDate.getDay();
		var currentMonth = currentDate.getMonth()+1;
       	diff = currentDate.getDate() - currentDay + (currentDay == 0 ? -6:1);
		var weekStart = new Date(currentDate.setDate(diff));		
		var startDate = weekStart.getFullYear()+'-'+padLeft(currentMonth)+'-'+padLeft(weekStart.getDate());		
	} else if (interval=='allTime') {
		var startDate='2005-01-01';
}	

And the biolerplate is done. All the input there and we are ready to issue a request to the analytics API. But before you can do that, you have to use yarn to add the googleapis module to the project (issue yarn add googleapis from the core folder, as per my previous post) and load the analytics module:

const { google } = require('googleapis');
module.exports = function top(options) {
  // ...
}

Finally comes the request — almost. First, two other pieces of data has to be retrieved: a client email and a private key to authenticate and authorize a "user" that can download the data. To set up your Google Analytics property with a "user" like this is actually quite complicated in my opinion and I'm not going to describe the details here (check out the project's wiki on how to do this). I decided that the best place to store these data is to put them in environment variables:

module.exports = function top(options) {  	
  // check parameters
  // calculate startdate
  var scopes = ['https://www.googleapis.com/auth/analytics.readonly'];		
  var key = {
    client_email:process.env.GA_CLIENT_EMAIL,
    private_key:process.env.GA_PRIVATE_KEY
  };	
}

In the code you can also see a scopes array. These scopes determine the type of operations that you can run after authentication.

And finally, the last part: authentication, getting the data, transforming and outputting the data.

const { google } = require('googleapis');

module.exports = function top(options) {  	
  // check parameters
  // calculate startdate
  // get auth info
  var authClient = new google.auth.JWT(key.client_email, null, key.private_key, scopes);	
  return authClient.authorize().then(function() {
			return google.analytics('v3').data.ga.get({ 
				'auth': authClient,
				'ids': 'ga:'+viewId,
				'start-date': startDate,
				'end-date': 'today',
				'metrics': 'ga:pageviews',
				'dimensions':'ga:pagePath,ga:pageTitle',
				'sort':'-ga:pageViews',                                                 'filters':'ga:pagePath!@/tag/;ga:pagePath!@/page/;ga:pagePath!=/;ga:pageTitle!=(not set);ga:pagePath!@&',
                'max-results':maxItems
         }).then(function(resp) {
              var data=[];	
              var result='';
              resp.data.rows.forEach( 
                 (row) => { data.push( { 'postUrl':row[0], 'postTitle':row[1], 'viewCount':row[2] } );  } );		
                 data.forEach( (item) => { result = result + options.fn(item) });
				 return result; })
            .catch(function(err) { return err.toString() });								
	});
}

After authorization, the data is downloaded. I think the code is pretty self-explanatory. The filter parameter is constructed so that it filters out pageviews for the tags, the main page, the paginated pages, anything where the title is not set or anything that has & character in it (I did this because I found some weird requests to my site containing these characters, that were obviously bogous). When the data is downloaded, it is transformed in yet another callback. First I build an array containing objects with the appropriate properties. Then, the array is iterated over and every item is passed to options.fn. This will transform every object from the array according to the inner template and append it to the result. Note that there are all sorts of returns in the code. I have no idea why this works, I just know that this does (it must be a 'promise from a promise inside a promise'-like thing :)) Also note that there's virtually no error handling. That's mostly because I have no idea how to do that properly and integrate with Ghost's own error handling mechanisms. Again, if you have any idea, feel free to share it in the comments.

And that's it! Now it is ready to be used. I used this helper in my Greatest hits page, just threw some css over it.

Check out the Github page on more info on how to set it up and get the authorization info or see the full source code.

Akos Nagy
Posted in Blogging