A light-weight ad engine using Groovy and jQuery

A recent in-house project required a customisable, light-weight ad engine (yes, I know - another ad engine) and so I decided to take a stab at it.

Fortunately, the requirements were pretty clear:

  • Create a small, embeddable script that can be pasted into any web page (à la Google Analytics)
  • Create an ad widget that can:
    • Rotate through preset ad banners/adverts
    • Record impressions
    • Record clicks (per ad)
    • Record the total time that ad has been displayed

Simple stuff, if you're using jQuery (and Groovy).

Here's what the embeddable snippet ended up looking like:

<!-- The embedded script -->  
<script>  
    (function (d, script) {
        var _espURL = "http://esp.office.i3zone.com"
        script = d.createElement('script');
        script.type = 'text/javascript';
        script.async = true;
        script.onload = function () {
            EspWidget._url = _espURL
            EspWidget.init("_esp")
        };
        script.src = _espURL + '/statics/com/esp/esp.js';
        d.getElementsByTagName('head')[0].appendChild(script);
    }(document));
</script>

<!-- The HTML placeholder -->  
<div  
        id="_esp"
        esp-campaign-id="3f62345736d84e86931eb8731e1254a1"
        esp-ad-speed="10000">
</div>  

The script will execute in-place and do a number of things:

  • Create and append a script (esp.js in our case).
  • Execute certain logic within esp.js, after it has finished loading.
  • Make an AJAX call to retrieve and inject HTML from some remote source.
  • Once the call has finished, kick off ad rotation, log the impression and start the timer.

Here's what esp.js looks like:

var EspWidget = {

    elem: '',
    _attrs: {},
    _url: '',

    timer: '',
    _timeRunning: 0,

    init: function (id) {
        EspWidget.elem = $("#" + id);
        EspService._setWidgetAttrs(EspWidget.elem);
        EspService.injectHTML(function () {
            EspWidget.startTimer();
            EspWidget.cycleThroughAds();
            EspWidget.logImpression();
        });
    },

    startTimer: function () {
        EspWidget.timer = setInterval(function () {
            EspWidget.incrementTimer();
        }, 1000);
    },

    incrementTimer: function () {
        EspWidget._timeRunning += 1;
        if (EspWidget._timeRunning % 20 == 0) {
           var campaignId = EspService._getCampaignId();
           EspService.postAdData(campaignId, null, {secondsRunning: EspWidget._timeRunning} );

           EspWidget._timeRunning = 0;
        }
    },

    clickAd: function (advertId, meta) {
        var campaignId = EspService._getCampaignId();
        EspService.postAdData(campaignId, advertId, meta);
    },

    logImpression: function(){
        var campaignId = EspService._getCampaignId();
        EspService.postAdData(campaignId, null, {logImpression: 1});
    },

    cycleThroughAds: function () {
        var divs = $('div[id^="content-"]').hide();
        var i = 0;

        (function cycle() {
            divs.eq(i)
                .fadeIn(400)
                .delay(EspService._getAdSpeed())
                .fadeOut(400, cycle);
            i = ++i % divs.length;
        })();
    }

};

var EspService = {

    injectHTML: function (callback) {
        $.ajax({
            type: "GET",
            url: EspWidget._url + '/widget/renderHTML',
            data: {
                "espUrl": EspWidget._url,
                "campaignId": EspService._getCampaignId()
            },
            success: function (result) {
                EspWidget.elem.html(result);
                if (callback) callback()
            }
        });
    },

    postAdData: function (campaignId, advertId, meta) {
        if(!meta) meta = {};
        if(campaignId) meta.campaignId = campaignId;
        if(advertId) meta.advertId = advertId;

        $.ajax({
            type: "GET",
            url: EspWidget._url + '/widget/postAdData',
            data: meta,
            success: function (result) {

            }
        });
    },

    /**
     * ESP widget attribute getters and setters
     */

    _getCampaignId: function () {
        var campaignId = EspWidget._attrs["esp-campaign-id"];
        if (!campaignId) console.error("ESP: No campaign ID set!");
        return EspWidget._attrs["esp-campaign-id"];
    },

    _getAdSpeed: function () {
        return parseFloat(EspWidget._attrs["esp-ad-speed"]) || 1000;
    },

    _setWidgetAttrs: function (elem) {
        $(elem).each(function () {
            $.each(this.attributes, function () {
                if (this.specified && this.name.startsWith("esp")) {
                    EspWidget._attrs[this.name] = this.value;
                }
            });
        });
    }
};

The code should be pretty explanatory, but there are two points that might need clarification:

  • In EspService:
    • _setWidgetAttrs runs through the various data-attributes on the HTML component (such as "esp-campaign-id" etc).
    • postAdData is a generic AJAX call that takes three arguments 'campaignId', 'advertId' and 'meta'. The last property is a placeholder for a meta property (such as incrementClicks, logImpression).
  • injectHTML is specific AJAX call used to retrieve pre-built HTML from the server (server-side rendering is a lot easier in our current technology stack).

Both of the AJAX calls point to the same Grails controller:

import org.grails.web.util.WebUtils

class EspWidget {

    def renderHTML(session, params){
        setResponseHeader()
        def campaign = o.campaign.getOne(params.campaignId)
        def adverts = o.advert.find([campaign: campaign])
        [as: 'html', model: [espUrl: params.espUrl, campaign: campaign, adverts:adverts], view: 'widget-html' ]
    }

    def postAdData(session, params) {
        setResponseHeader()
        o.espService.postAdData(params)
        [as: 'json', model:  [success: true] ]
    }

    private setResponseHeader(){
        def webUtils = WebUtils.retrieveGrailsWebRequest()
        def response = webUtils.getCurrentResponse()
        def request = webUtils.getCurrentRequest()
        response.setHeader("Access-Control-Allow-Origin", "*");
        response.setHeader("Access-Control-Allow-Methods", request.getHeader("Access-Control-Request-Methods"));
        response.setHeader("Access-Control-Allow-Headers", request.getHeader("Access-Control-Request-Headers"));
    }
}

A final thought: please note the setResponseHeader() function. Without this we'd get an SOP error. Bit of a sledge-hammer, but this allows for adding the embeddable script in virtually any site running jQuery.