diff --git a/modules/browsiProvider.js b/modules/browsiProvider.js new file mode 100644 index 00000000000..0ae39fe66dd --- /dev/null +++ b/modules/browsiProvider.js @@ -0,0 +1,215 @@ +/** + * This module adds browsi provider to the eal time data module + * The {@link module:modules/realTimeData} module is required + * The module will fetch predictions from browsi server + * The module will place browsi bootstrap script on page + * @module modules/browsiProvider + * @requires module:modules/realTimeData + */ + +/** + * @typedef {Object} ModuleParams + * @property {string} siteKey + * @property {string} pubKey + * @property {string} url + * @property {string} keyName + */ + +import {config} from '../src/config.js'; +import * as utils from '../src/utils'; +import {submodule} from '../src/hook'; + +/** @type {string} */ +const MODULE_NAME = 'realTimeData'; +/** @type {ModuleParams} */ +let _moduleParams = {}; + +export let _resolvePromise = null; +const _waitForData = new Promise(resolve => _resolvePromise = resolve); + +/** + * add browsi script to page + * @param {string} bptUrl + */ +export function addBrowsiTag(bptUrl) { + let script = document.createElement('script'); + script.async = true; + script.setAttribute('data-sitekey', _moduleParams.siteKey); + script.setAttribute('data-pubkey', _moduleParams.pubKey); + script.setAttribute('src', bptUrl); + document.head.appendChild(script); + return script; +} + +/** + * collect required data from page + * send data to browsi server to get predictions + */ +function collectData() { + const win = window.top; + let historicalData = null; + try { + historicalData = JSON.parse(utils.getDataFromLocalStorage('__brtd')) + } catch (e) { + utils.logError('unable to parse __brtd'); + } + + let predictorData = { + ...{ + sk: _moduleParams.siteKey, + sw: (win.screen && win.screen.width) || -1, + sh: (win.screen && win.screen.height) || -1, + }, + ...(historicalData && historicalData.pi ? {pi: historicalData.pi} : {}), + ...(historicalData && historicalData.pv ? {pv: historicalData.pv} : {}), + ...(document.referrer ? {r: document.referrer} : {}), + ...(document.title ? {at: document.title} : {}) + }; + getPredictionsFromServer(`//${_moduleParams.url}/bpt?${serialize(predictorData)}`); +} + +/** + * filter server data according to adUnits received + * @param {adUnit[]} adUnits + * @return {Object} filtered data + * @type {(function(adUnit[]): Promise<(adUnit | {}) | never | {}>)}} + */ +function sendDataToModule(adUnits) { + return _waitForData + .then((_predictions) => { + if (!_predictions) { + resolve({}) + } + const slots = getAllSlots(); + if (!slots) { + resolve({}) + } + let dataToResolve = adUnits.reduce((rp, cau) => { + const adUnitCode = cau && cau.code; + if (!adUnitCode) { return rp } + const predictionData = _predictions[adUnitCode]; + if (!predictionData) { return rp } + + if (predictionData.p) { + if (!isIdMatchingAdUnit(adUnitCode, slots, predictionData.w)) { + return rp; + } + rp[adUnitCode] = getKVObject(predictionData.p); + } + return rp; + }, {}); + return (dataToResolve); + }) + .catch(() => { + return ({}); + }); +} + +/** + * get all slots on page + * @return {Object[]} slot GoogleTag slots + */ +function getAllSlots() { + return utils.isGptPubadsDefined && window.googletag.pubads().getSlots(); +} +/** + * get prediction and return valid object for key value set + * @param {number} p + * @return {Object} key:value + */ +function getKVObject(p) { + const prValue = p < 0 ? 'NA' : (Math.floor(p * 10) / 10).toFixed(2); + let prObject = {}; + prObject[(_moduleParams['keyName'].toString())] = prValue.toString(); + return prObject; +} +/** + * check if placement id matches one of given ad units + * @param {number} id placement id + * @param {Object[]} allSlots google slots on page + * @param {string[]} whitelist ad units + * @return {boolean} + */ +export function isIdMatchingAdUnit(id, allSlots, whitelist) { + if (!whitelist || !whitelist.length) { + return true; + } + const slot = allSlots.filter(s => s.getSlotElementId() === id); + const slotAdUnits = slot.map(s => s.getAdUnitPath()); + return slotAdUnits.some(a => whitelist.indexOf(a) !== -1); +} + +/** + * XMLHttpRequest to get data form browsi server + * @param {string} url server url with query params + */ +function getPredictionsFromServer(url) { + const xmlhttp = new XMLHttpRequest(); + xmlhttp.onreadystatechange = function() { + if (xmlhttp.readyState === 4 && xmlhttp.status === 200) { + try { + var data = JSON.parse(xmlhttp.responseText); + _resolvePromise(data.p); + addBrowsiTag(data.u); + } catch (err) { + utils.logError('unable to parse data'); + } + } + }; + xmlhttp.onloadend = function() { + if (xmlhttp.status === 404) { + _resolvePromise(false); + utils.logError('unable to get prediction data'); + } + }; + xmlhttp.open('GET', url, true); + xmlhttp.onerror = function() { _resolvePromise(false) }; + xmlhttp.send(); +} + +/** + * serialize object and return query params string + * @param {Object} obj + * @return {string} + */ +function serialize(obj) { + var str = []; + for (var p in obj) { + if (obj.hasOwnProperty(p)) { + str.push(encodeURIComponent(p) + '=' + encodeURIComponent(obj[p])); + } + } + return str.join('&'); +} + +/** @type {RtdSubmodule} */ +export const browsiSubmodule = { + /** + * used to link submodule with realTimeData + * @type {string} + */ + name: 'browsi', + /** + * get data and send back to realTimeData module + * @function + * @param {adUnit[]} adUnits + * @returns {Promise} + */ + getData: sendDataToModule +}; + +export function init(config) { + const confListener = config.getConfig(MODULE_NAME, ({realTimeData}) => { + _moduleParams = realTimeData.params || {}; + if (_moduleParams.siteKey && _moduleParams.pubKey && _moduleParams.url && _moduleParams.keyName && + realTimeData.name && realTimeData.name.toLowerCase() === 'browsi') { + confListener(); + collectData(); + } else { + utils.logError('missing params for Browsi provider'); + } + }); +} + +submodule('realTimeData', browsiSubmodule); +init(config); diff --git a/modules/realTimeData.md b/modules/realTimeData.md new file mode 100644 index 00000000000..0dcdb123dc4 --- /dev/null +++ b/modules/realTimeData.md @@ -0,0 +1,30 @@ +## Real Time Data Configuration Example + +Example showing config using `browsi` sub module +``` + pbjs.setConfig({ + "realTimeData": { + "name": "browsi", + "primary_only": false, + "params": { + "url": "testUrl.com", + "siteKey": "testKey", + "pubKey": "testPub", + "keyName":"bv" + } + } + }); +``` + +Example showing real time data object received form `browsi` sub module +``` +{ + "slotPlacementId":{ + "key":"value", + "key2":"value" + }, + "slotBPlacementId":{ + "dataKey":"dataValue", + } +} +``` diff --git a/modules/realTimeDataModule.js b/modules/realTimeDataModule.js new file mode 100644 index 00000000000..7361d7e8517 --- /dev/null +++ b/modules/realTimeDataModule.js @@ -0,0 +1,191 @@ +/** + * This module adds Real time data support to prebid.js + * @module modules/realTimeData + */ + +/** + * @interface RtdSubmodule + */ + +/** + * @function + * @summary return teal time data + * @name RtdSubmodule#getData + * @param {adUnit[]} adUnits + * @return {Promise} + */ + +/** + * @property + * @summary used to link submodule with config + * @name RtdSubmodule#name + * @type {string} + */ + +/** + * @interface ModuleConfig + */ + +/** + * @property + * @summary sub module name + * @name ModuleConfig#name + * @type {string} + */ + +/** + * @property + * @summary timeout + * @name ModuleConfig#timeout + * @type {number} + */ + +/** + * @property + * @summary params for provide (sub module) + * @name ModuleConfig#params + * @type {Object} + */ + +/** + * @property + * @summary primary ad server only + * @name ModuleConfig#primary_only + * @type {boolean} + */ + +import {getGlobal} from '../src/prebidGlobal'; +import {config} from '../src/config.js'; +import {targeting} from '../src/targeting'; +import {getHook, module} from '../src/hook'; +import * as utils from '../src/utils'; + +/** @type {string} */ +const MODULE_NAME = 'realTimeData'; +/** @type {number} */ +const DEF_TIMEOUT = 1000; +/** @type {RtdSubmodule[]} */ +let subModules = []; +/** @type {RtdSubmodule | null} */ +let _subModule = null; +/** @type {ModuleConfig} */ +let _moduleConfig; + +/** + * enable submodule in User ID + * @param {RtdSubmodule} submodule + */ +export function attachRealTimeDataProvider(submodule) { + subModules.push(submodule); +} +/** + * get registered sub module + * @returns {RtdSubmodule} + */ +function getSubModule() { + if (!_moduleConfig.name) { + return null; + } + const subModule = subModules.filter(m => m.name === _moduleConfig.name)[0] || null; + if (!subModule) { + throw new Error('unable to use real time data module without provider'); + } + return subModules.filter(m => m.name === _moduleConfig.name)[0] || null; +} + +export function init(config) { + const confListener = config.getConfig(MODULE_NAME, ({realTimeData}) => { + if (!realTimeData.name) { + utils.logError('missing parameters for real time module'); + return; + } + confListener(); // unsubscribe config listener + _moduleConfig = realTimeData; + // get submodule + _subModule = getSubModule(); + // delay bidding process only if primary ad server only is false + if (_moduleConfig['primary_only']) { + getHook('bidsBackCallback').before(setTargetsAfterRequestBids); + } else { + getGlobal().requestBids.before(requestBidsHook); + } + }); +} + +/** + * get data from sub module + * @returns {Promise} promise race - will return submodule config or false if time out + */ +function getProviderData(adUnits) { + // promise for timeout + const timeOutPromise = new Promise((resolve) => { + setTimeout(() => { + resolve(false); + }, _moduleConfig.timeout || DEF_TIMEOUT) + }); + + return Promise.race([ + timeOutPromise, + _subModule.getData(adUnits) + ]); +} + +/** + * run hook after bids request and before callback + * get data from provider and set key values to primary ad server + * @param {function} next - next hook function + * @param {AdUnit[]} adUnits received from auction + */ +export function setTargetsAfterRequestBids(next, adUnits) { + getProviderData(adUnits).then(data => { + if (data && Object.keys(data).length) { // utils.isEmpty + setDataForPrimaryAdServer(data); + } + next(adUnits); + } + ); +} + +/** + * run hook before bids request + * get data from provider and set key values to primary ad server & bidders + * @param {function} fn - hook function + * @param {Object} reqBidsConfigObj - request bids object + */ +export function requestBidsHook(fn, reqBidsConfigObj) { + getProviderData(reqBidsConfigObj.adUnits || getGlobal().adUnits).then(data => { + if (data && Object.keys(data).length) { + setDataForPrimaryAdServer(data); + addIdDataToAdUnitBids(reqBidsConfigObj.adUnits || getGlobal().adUnits, data); + } + return fn.call(this, reqBidsConfigObj.adUnits); + }); +} + +/** + * set data to primary ad server + * @param {Object} data - key values to set + */ +function setDataForPrimaryAdServer(data) { + if (!utils.isGptPubadsDefined()) { + utils.logError('window.googletag is not defined on the page'); + return; + } + targeting.setTargetingForGPT(data, null); +} + +/** + * @param {AdUnit[]} adUnits + * @param {Object} data - key values to set + */ +function addIdDataToAdUnitBids(adUnits, data) { + adUnits.forEach(adUnit => { + adUnit.bids.forEach(bid => { + const rd = data[adUnit.code] || {}; + bid = Object.assign(bid, rd); + }); + }); +} + +init(config); +module('realTimeData', attachRealTimeDataProvider); diff --git a/src/auction.js b/src/auction.js index a1e8c33adfb..748affa0201 100644 --- a/src/auction.js +++ b/src/auction.js @@ -154,29 +154,31 @@ export function newAuction({adUnits, adUnitCodes, callback, cbTimeout, labels, a _auctionEnd = Date.now(); events.emit(CONSTANTS.EVENTS.AUCTION_END, getProperties()); - try { - if (_callback != null) { - const adUnitCodes = _adUnitCodes; - const bids = _bidsReceived - .filter(utils.bind.call(adUnitsFilter, this, adUnitCodes)) - .reduce(groupByPlacement, {}); - _callback.apply($$PREBID_GLOBAL$$, [bids, timedOut]); - _callback = null; - } - } catch (e) { - utils.logError('Error executing bidsBackHandler', null, e); - } finally { - // Calling timed out bidders - if (timedOutBidders.length) { - adapterManager.callTimedOutBidders(adUnits, timedOutBidders, _timeout); - } - // Only automatically sync if the publisher has not chosen to "enableOverride" - let userSyncConfig = config.getConfig('userSync') || {}; - if (!userSyncConfig.enableOverride) { - // Delay the auto sync by the config delay - syncUsers(userSyncConfig.syncDelay); + bidsBackCallback(_adUnitCodes, function () { + try { + if (_callback != null) { + const adUnitCodes = _adUnitCodes; + const bids = _bidsReceived + .filter(utils.bind.call(adUnitsFilter, this, adUnitCodes)) + .reduce(groupByPlacement, {}); + _callback.apply($$PREBID_GLOBAL$$, [bids, timedOut]); + _callback = null; + } + } catch (e) { + utils.logError('Error executing bidsBackHandler', null, e); + } finally { + // Calling timed out bidders + if (timedOutBidders.length) { + adapterManager.callTimedOutBidders(adUnits, timedOutBidders, _timeout); + } + // Only automatically sync if the publisher has not chosen to "enableOverride" + let userSyncConfig = config.getConfig('userSync') || {}; + if (!userSyncConfig.enableOverride) { + // Delay the auto sync by the config delay + syncUsers(userSyncConfig.syncDelay); + } } - } + }) } } @@ -328,6 +330,12 @@ export const addBidResponse = hook('async', function(adUnitCode, bid) { this.dispatch.call(this.bidderRequest, adUnitCode, bid); }, 'addBidResponse'); +export const bidsBackCallback = hook('async', function (adUnits, callback) { + if (callback) { + callback(); + } +}, 'bidsBackCallback'); + export function auctionCallbacks(auctionDone, auctionInstance) { let outstandingBidsAdded = 0; let allAdapterCalledDone = false; diff --git a/src/config.js b/src/config.js index 7645da18d8f..40831d7de6b 100644 --- a/src/config.js +++ b/src/config.js @@ -306,11 +306,12 @@ export function newConfig() { return; } - listeners.push({ topic, callback }); + const nl = { topic, callback }; + listeners.push(nl); // save and call this function to remove the listener return function unsubscribe() { - listeners.splice(listeners.indexOf(listener), 1); + listeners.splice(listeners.indexOf(nl), 1); }; } diff --git a/test/spec/modules/realTimeModule_spec.js b/test/spec/modules/realTimeModule_spec.js new file mode 100644 index 00000000000..34ae0c49aa9 --- /dev/null +++ b/test/spec/modules/realTimeModule_spec.js @@ -0,0 +1,158 @@ +import { + init, + requestBidsHook, + attachRealTimeDataProvider, + setTargetsAfterRequestBids +} from 'modules/realTimeDataModule'; +import { + init as browsiInit, + addBrowsiTag, + isIdMatchingAdUnit +} from 'modules/browsiProvider'; +import {config} from 'src/config'; +import {browsiSubmodule, _resolvePromise} from 'modules/browsiProvider'; +import {makeSlot} from '../integration/faker/googletag'; + +let expect = require('chai').expect; + +describe('Real time module', function() { + const conf = { + 'realTimeData': { + 'name': 'browsi', + 'primary_only': false, + 'params': { + 'url': 'testUrl.com', + 'siteKey': 'testKey', + 'pubKey': 'testPub', + 'keyName': 'bv' + } + } + }; + + const predictions = + { + 'browsiAd_2': { + 'w': [ + '/57778053/Browsi_Demo_Low', + '/57778053/Browsi_Demo_300x250' + ], + 'p': 0.07 + }, + 'browsiAd_1': { + 'w': [], + 'p': 0.06 + }, + 'browsiAd_3': { + 'w': [], + 'p': 0.53 + }, + 'browsiAd_4': { + 'w': [ + '/57778053/Browsi_Demo' + ], + 'p': 0.85 + } + }; + + function getAdUnitMock(code = 'adUnit-code') { + return { + code, + mediaTypes: {banner: {}, native: {}}, + sizes: [[300, 200], [300, 600]], + bids: [{bidder: 'sampleBidder', params: {placementId: 'banner-only-bidder'}}] + }; + } + + function createSlots() { + const slot1 = makeSlot({code: '/57778053/Browsi_Demo_300x250', divId: 'browsiAd_1'}); + const slot2 = makeSlot({code: '/57778053/Browsi_Demo_Low', divId: 'browsiAd_2'}); + return [ + slot1, + slot2 + ]; + } + + before(function() { + + }); + + describe('Real time module with browsi provider', function() { + afterEach(function () { + $$PREBID_GLOBAL$$.requestBids.removeAll(); + }); + + it('check module using bidsBackCallback', function () { + let adUnits1 = [getAdUnitMock('browsiAd_1')]; + _resolvePromise(predictions); + attachRealTimeDataProvider(browsiSubmodule); + init(config); + browsiInit(config); + config.setConfig(conf); + + // set slot + const slots = createSlots(); + window.googletag.pubads().setSlots(slots); + + setTargetsAfterRequestBids(afterBidHook, {adUnits: adUnits1}); + function afterBidHook() { + slots.map(s => { + let targeting = []; + s.getTargeting().map(value => { + console.log('in slots map'); + let temp = []; + temp.push(Object.keys(value).toString()); + temp.push(value[Object.keys(value)]); + targeting.push(temp); + }); + expect(targeting.indexOf('bv')).to.be.greaterThan(-1); + }); + } + }); + + it('check module using requestBidsHook', function () { + let adUnits1 = [getAdUnitMock('browsiAd_1')]; + + // set slot + const slotsB = createSlots(); + window.googletag.pubads().setSlots(slotsB); + + requestBidsHook(afterBidHook, {adUnits: adUnits1}); + function afterBidHook(adUnits) { + adUnits.forEach(unit => { + unit.bids.forEach(bid => { + expect(bid).to.have.property('bv'); + }); + }); + + slotsB.map(s => { + let targeting = []; + s.getTargeting().map(value => { + let temp = []; + temp.push(Object.keys(value).toString()); + temp.push(value[Object.keys(value)]); + targeting.push(temp); + }); + expect(targeting.indexOf('bv')).to.be.greaterThan(-1); + }); + } + }); + + it('check browsi sub module', function () { + const script = addBrowsiTag('scriptUrl.com'); + expect(script.getAttribute('data-sitekey')).to.equal('testKey'); + expect(script.getAttribute('data-pubkey')).to.equal('testPub'); + expect(script.async).to.equal(true); + + const slots = createSlots(); + const test1 = isIdMatchingAdUnit('browsiAd_1', slots, ['/57778053/Browsi_Demo_300x250']); // true + const test2 = isIdMatchingAdUnit('browsiAd_1', slots, ['/57778053/Browsi_Demo_300x250', '/57778053/Browsi']); // true + const test3 = isIdMatchingAdUnit('browsiAd_1', slots, ['/57778053/Browsi_Demo_Low']); // false + const test4 = isIdMatchingAdUnit('browsiAd_1', slots, []); // true + + expect(test1).to.equal(true); + expect(test2).to.equal(true); + expect(test3).to.equal(false); + expect(test4).to.equal(true); + }) + }); +});