Building a realtime Facebook like counter with NodeJS and jQuery

| Reading time: 7 min

A few months ago I worked on a project for the Air Canada Foundation that allowed users to donate their Aeroplan miles to the Air Canada Foundation by way of a Facebook page. For every mile that was donated by a user, Air Canada would match. For users that did not have an Aeroplan account they could "like" the page and Air Canada would donate 10 miles on the user's behalf.

My design for the Air Canada Foundation Facebook page

The plan was to have the counter on the top right update in realtime whenever a user made a donation through the Aeroplan miles system or "liked" the page on Facebook and animate the numbers. We had access to an API that returned results in JSON format for the Aeroplan side of things but I had to get the same information from Facebook every time someone "liked" the page.

Realtime donation counter

I thought that others might benefit from what I learned in the process of making this and while the full solution would be too long to write and I can't actually share it, I can offer a simplified version that you can adapt to suit your own needs.

What I will cover:

  • Write a NodeJS server to get the like count of a specific user/page on Facebook at a set interval and store it to a text file.
  • Use jQuery to fetch the count number from the text file and animate a counter/odometer with the new value.

The solution I needed did not allow subtraction of values so this counter does not handle subtraction gracefully (it jumps).

You can obviously just use jQuery to call for the number directly from Facebook but if you get a surge in traffic to your page with the counter it will make more calls than necessary to Facebook's servers, possibly resulting in errors. The NodeJS server just acts as a buffer.

Accessing the "like" count

After some digging around I found the URL that I could query http://graph.facebook.com/19292868552. Where 19292868552 is the page/person ID. The URL returns a JSON object containing likes as one of its properties.


{
  "about": "Grow your app with Facebook\nhttps://developers.facebook.com/ ",
  "category": "Product/service",
  "company_overview": "Facebook Platform enables anyone to build social apps on Facebook, mobile, and the web.\n\n",
  "is_published": true,
  "talking_about_count": 55297,
  "username": "FacebookDevelopers",
  "website": "http://developers.facebook.com",
  "were_here_count": 0,
  "id": "19292868552",
  "name": "Facebook Developers",
  "link": "http://www.facebook.com/FacebookDevelopers",
  "likes": 1358310,
  "cover": {
    "cover_id": "10151383067598553",
    "source": "http://sphotos-g.ak.fbcdn.net/hphotos-ak-ash3/s720x720/553181_10151383067598553_2069064298_n.png",
    "offset_y": 0,
    "offset_x": 0
  }
}

Some more digging around the HTML source code of an actual Facebook page revealed the ID that I needed.

Finding the ID in Facebook's ever-changing source code

I found a great writeup on querying Facebook using NodeJS which helped get the ball rolling but in order to get all of the pieces working together on the backend I had to expand on this quite a bit for what I needed. You may find it sufficient.

The NodeJS Server

Here is the code used for the Facebook NodeJS portion:

// I like modularity and debugging :)

// Require settings
var http                = require("http");
var sys                 = require("sys");
var events              = require("events");
var url                 = require("url");
var path                = require("path");
var fs                  = require('fs');

// Input variables
var ping_int            = 2000;
var waitTime            = 5; // minutes
var throttle_int        = 1000 * 60 * waitTime; // for readability

// Facebook
var data_host           = 'graph.facebook.com';
var data_path           = '/19292868552'; // replace with your ID

var httpOptions         = { host: data_host, port: 80, path: data_path, method: 'GET' };

// Output variables (static text files)
var count_db            = "../data/count.txt"; // you create this yourself

// Build variables
var result_emitter      = new events.EventEmitter();

// Listen for updates
var listener = result_emitter.on("data", function(outcome) {
    if (outcome === 200) { setTimeout( get_data, ping_int ); }
    // The response failed, throttle requests
    else {
        var reqDate = new Date();
        console.log("Throttle connection for " + throttle_int + "ms (" + waitTime + "mins). " + reqDate);
        setTimeout( get_data, throttle_int );
    }
    // Log error
}, "error", function(error) {
    console.log("A result_emitter error has been caught");
    console.log("Carry on...");
    // Log exception
}, "uncaughtException", function(uncaughtException) {
    console.log("An uncaught exception has been caught");
    console.log("Carry on...");
});

// The meat
function get_data() {

    var reqDate = new Date();
    var request = http.request( httpOptions );

    request.addListener("response", function(response) {

        console.log("GET request sent: " + reqDate);

        response.setEncoding('utf8');
        var body = "";

        response.addListener("data", function(data) {
            body += data;
        });

        response.addListener("end", function() {

            var data        = JSON.parse(body);
            var result      = Math.round(data.likes);
            console.log("result: " + result);

            // READING FROM FILES
            var file_check  = fs.existsSync(count_db);
            if (file_check) {
                // Read stored value
                var old_val             = fs.readFileSync(count_db, 'utf8');
                console.log("old value: " + old_val);

                // If the new value is different than the last time we checked
                if (result !== old_val) {
                    console.log("new_val: " + result);
                    // Write result to staic DB (text file)
                    fs.writeFileSync(count_db, result, 'utf8');
                    console.log("DBs updated: " + reqDate + " with " + result);
                }
            }
        });

        result_emitter.emit("data", response.statusCode);
    });
    request.end();
}
// Start requesting data
get_data();

As you can see, I wasn't using NodeJS in its intended form (asynchronously) and used the Sync method instead because doing it asynchronously ended up making the ping interval irrelevant and caused errors for me.

If you just run node counter.js from the directory containing that file in your terminal it will start to output the data to the text files that you specified on line 23.

The HTML

The HTML and CSS for this is mostly from a blog post that I found but I had to change the Javascript quite a bit to get it to work the way I needed it to.

The CSS

body {
  margin: 0;
  font-family: "Helvetica Neue", Helvetica, Arial, sans-serif;
  font-size: 14px;
  line-height: 20px;
  color: #002856;
  background-color: transparent;
}

#counter {
  text-transform: uppercase;
  text-align: center;
  margin: 20px auto;
  width: 310px;
}
.counter {
  display: block;
  float: left;
  font-size: 42px;
  line-height: 55px;
  margin-bottom: 15px;
}
.counter span.digit {
  background: #5974ed;
  background-clip: border;
  background-color: #5974ed;
  background-image: -moz-linear-gradient(top, #5974ed, #2b46a7);
  background-image: -webkit-gradient(linear, 0 0, 0 100%, from(#5974ed), to(#2b46a7));
  background-image: -webkit-linear-gradient(top, #5974ed, #2b46a7);
  background-image: -o-linear-gradient(top, #5974ed, #2b46a7);
  background-image: linear-gradient(to bottom, #5974ed, #2b46a7);
  background-repeat: repeat-x;
  filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff5974ed', endColorstr='#ff2b46a7', GradientType=0);
  -webkit-border-radius: 5px;
  -moz-border-radius: 5px;
  border-radius: 5px;
  -webkit-box-shadow: 0px 0px 5px rgba(0, 0, 0, 0.5);
  -moz-box-shadow: 0px 0px 5px rgba(0, 0, 0, 0.5);
  box-shadow: 0px 0px 5px rgba(0, 0, 0, 0.5);
  border: 1px solid #e1e1e1;
  zoom: 1;
  color: #ffffff;
  display: block;
  float: left;
  height: 55px;
  margin: 0 1.9px;
  overflow: hidden;
  padding: 0;
  position: relative;
  text-align: center;
  width: 35px;
  font-weight: 700;
  text-shadow: 0 -1px 1px rgba(0, 0, 0, 0.5), 0 1px 0 #ffffff;
}
.counter span.digit span {
  line-height: 44px;
  position: relative;
  top: 0;
}
.counter span.separator {
  display: block;
  float: left;
  position: relative;
  top: 0.5em;
  font-size: 32px;
  line-height: 150%;
  color: #2b46a7;
}

a {
  color: #2b46a7;
  letter-spacing: .2em;
  text-decoration: none;
}

The Javascript

var refreshcounts               = 0;

var likeInterval                = 3 * 1000;
var killTime                    = 5; // minutes (whole number)
var stopTimer                   = 1000 * 30 * killTime; // Lets be nice to the servers (1 sec x 60 = 60 secs x 10 = 10 mins)
// Server
var count_url                   = "data/count.txt";

// Call for values
function update_count() {

    var data            = get_count();
    var count           = Math.round(data);

    // Prepare counter values
    var digits          = [];
    var sNumber         = count.toString();
    var counterLength   = 7;
    var valLength       = sNumber.length;

    for (var i = 0, len = counterLength; i < len; i += 1) {
        // Add zeros to the front if less than 1,000,000
        var offset = counterLength - valLength;
        digits.push(+sNumber.charAt(i - offset));
    }
    return digits;
}

// Read text file
function get_count() {
    $.ajax({
        url: count_url,
        type: 'get',
        dataType: 'json',
        async: false,
        success: function(data) {
            count = data;
        }
    });
    return count;
}

// Turn off live updating after X minutes to help servers (killTime)
function stop_counting() {
    clearInterval(refreshcounts);
    console.log("refresh page to see updates");
}

function initCounter(newVal){

    $(".counter").find('.digit').each(function(i){
        var $display = $(this);
        var $digit = $display.find('span');
        var $oldNum = $digit.attr('title'); //for starting animation point
        // Create numbers
        $digit.html([0,1,2,3,4,5,6,7,8,9,0].reverse().join('<br/>'));
        // set css top property to that of the span height * title value
        $digit.css({
            top: '-' + (Math.round($display.height()) * (10 - Math.round($digit.attr('title')))) + 'px'
        });
        // set title values to new values ready for animation
        $digit.attr('title', newVal[i]);
        // animate each counter digit
        animateDigit($display,$oldNum);
    });
}

function update_counter(newVal){

    $(".counter").find('.digit').each(function(i){
        var $display = $(this);
        var $digit = $display.find('span');
        var $oldNum = $digit.attr('title');

        $digit.attr('title', newVal[i]);

        animateDigit($display, $oldNum);
    });
}

function animateDigit(displayin, old){
    var $display = displayin;
    var $digit = $display.find('span');
    var $newNum = Math.round($digit.attr("title"));
    var $oldNum = Math.round(old);
    var $diff = $newNum - $oldNum;

    if ($diff == "-9") { // if we're at 9 and going to 0, increase by one then reset span position to starting val
        $digit.animate({ top: '+=' + 55 + 'px' }, 1000, function() {
            $digit.css({ top: '-' + 550 + 'px' });
        });
    } else if ($newNum < $oldNum && $diff !== 0) { // if the new val is less than the old val spin forward
        var posMove = Math.abs($diff);
        $digit.animate({ top: '+=' + 55 * (10-$oldNum) + 'px' }, 500, function() {
            $digit.css({ top: '-' + 550 + 'px' });
        });
        $digit.animate({ top: '+=' + 55 * $newNum + 'px' }, 1000);
    } else { // in any other case
        $digit.animate({ top: '+=' + $display.height() * $diff + 'px' }, 1000);
    }
}

function update_all() {
    var newVal = update_count();
    update_counter(newVal);
}

var newVal = update_count();

$("#start").click(function(e) {
    e.preventDefault();
    $(this).remove();
    setTimeout("initCounter(newVal)", 1500);
    refreshcounts = setInterval("update_all()", likeInterval);
    setTimeout("stop_counting()", stopTimer);
});

Demo

I'm not going to run a NodeJS server just for the purposes of this post so you will have to run this locally using the download link at the end of this post or write your own to see the effect of you liking a page and watching it animate further.

To test this yourself, download the code then cd into the nodejs folder and run node counter.js. Open counter.html in a browser and click "start". As long as the terminal window remains open the count will be updated. If you left the ID as I wrote it this will update fairly often because it is Facebook's Developer page which updates a lot.

I used Forever so I didn't have to keep the window open and I also set the counter on the page to stop checking after 3 minutes to avoid killing your browser.

As I mentioned earlier, I did not build subtraction of valuers in so the counter jumps. With a bit of experimentation you could get it to work though.

Conclusion

With the limitations I had to work within (likes/donations could never go down, only up and cut off points/caps), the approach I took was to query Facebook for the "likes" count every 2 seconds and write it to a text file. I did the same for Aeroplan to get the number of miles donated. This was done with two separate NodeJS servers. Then I wrote a third NodeJS server to capture the output from those text files, calculate the other numbers needed for the source section of the counter and write them all to another text file in JSON format so that the front end code for the counter could easily make an ajax query to that file. The third "calculator" server had to kill one of its functions automatically once the donations reached a cap that Air Canada had set and then read from different files from that point on. I used Forever to keep the NodeJS servers running which made restarting them and monitoring really easy.

I'm sure there are probably other ways to get the number of likes but it seemed unnecessary to get into official Facebook apps and their API for this particular project and while a PHP, curl or cronjob could possibly have achieved the same result from a server/backend perspective, this approach allowed me to easily isolate problems when they occurred and update values manually if and when I needed to.