Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Category translation module for adpod #3513

Merged
merged 19 commits into from
Feb 27, 2019
Merged
Show file tree
Hide file tree
Changes from 9 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 36 additions & 2 deletions modules/appnexusBidAdapter.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { Renderer } from '../src/Renderer';
import * as utils from '../src/utils';
import { registerBidder } from '../src/adapters/bidderFactory';
import { BANNER, NATIVE, VIDEO } from '../src/mediaTypes';
import { registerBidder, getIabSubCategory } from '../src/adapters/bidderFactory';
import { BANNER, NATIVE, VIDEO, ADPOD } from '../src/mediaTypes';
import find from 'core-js/library/fn/array/find';
import includes from 'core-js/library/fn/array/includes';

Expand Down Expand Up @@ -32,6 +32,7 @@ const NATIVE_MAPPING = {
displayUrl: 'displayurl'
};
const SOURCE = 'pbjs';
const mappingFileUrl = 'https://api.myjson.com/bins/11f7yo';

export const spec = {
code: BIDDER_CODE,
Expand Down Expand Up @@ -209,6 +210,24 @@ export const spec = {
return bids;
},

/**
* @typedef {Object} mappingFileInfo
* @property {string} url mapping file json url
* @property {number} refreshInDays prebid stores mapping data in localstorage so you can return in how many days you want to update value stored in localstorage.
* @property {string} localStorageKey unique key to store your mapping json in localstorage
*/

/**
* Returns mapping file info. This info will be used by bidderFactory to preload mapping file and store data in local storage
* @returns {mappingFileInfo}
*/
getMappingFileInfo: function() {
return {
url: mappingFileUrl,
refreshInDays: 7
}
},

getUserSyncs: function(syncOptions) {
if (syncOptions.iframeEnabled) {
return [{
Expand Down Expand Up @@ -316,6 +335,21 @@ function newBid(serverBid, rtbBid, bidderRequest) {
vastImpUrl: rtbBid.notify_url,
ttl: 3600
});

const videoContext = utils.deepAccess(bidRequest, 'mediaTypes.video.context');
if (videoContext === ADPOD) {
const iabSubCatId = getIabSubCategory(bidRequest.bidder, rtbBid.brand_category_id);

bid.meta = {
iabSubCatId
};

bid.video = {
context: ADPOD,
durationSeconds: Math.ceil(rtbBid.rtb.video.duration_ms / 1000),
};
}

// This supports Outstream Video
if (rtbBid.renderer_url) {
const rendererOptions = utils.deepAccess(
Expand Down
101 changes: 101 additions & 0 deletions modules/categoryTranslation.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
/**
* This module translates iab category to freewheel industry using translation mapping file
* Publisher can set translation file by using setConfig method
*
* Example:
* config.setConfig({
* 'brandCategoryTranslation': {
* 'translationFile': 'http://sample.com'
* }
* });
* If publisher has not defined translation file than prebid will use default prebid translation file provided here <TODO add url once it is uploaded on cdn>
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Todo here.

*/

import { config } from '../src/config';
import { hooks, hook } from '../src/hook';
import { ajax } from '../src/ajax';
import { timestamp, logError, setDataInLocalStorage, getDataFromLocalStorage } from '../src/utils';

// TODO udpate url once it is uploaded on cdn
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

TODO can removed.

const DEFAULT_TRANSLATION_FILE_URL = 'https://api.myjson.com/bins/j5d0k';
const DEFAULT_IAB_TO_FW_MAPPING_KEY = 'iabToFwMappingkey';
const DEFAULT_IAB_TO_FW_MAPPING_KEY_PUB = 'iabToFwMappingkeyPub';
const refreshInDays = 1;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

should we increase default to 7?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I have kept it 1 because if publisher changes the mapping file, localStorage will be updated in everyone's browser within 24 hours. So per day 1 request.


let adServerInUse;
export const registerAdserver = hook('async', function(adServer) {
adServerInUse = adServer;
}, 'registerAdserver');

export function getAdserverCategoryHook(fn, adUnitCode, bid) {
if (!bid) {
return fn.call(this, adUnitCode); // if no bid, call original and let it display warnings
}
if (!adServerInUse) {
registerAdserver();
}

let localStorageKey = (config.getConfig('brandCategoryTranslation.translationFile')) ? DEFAULT_IAB_TO_FW_MAPPING_KEY_PUB : DEFAULT_IAB_TO_FW_MAPPING_KEY;

if (bid.meta && !bid.meta.adServerCatId) {
let mapping = getDataFromLocalStorage(localStorageKey);
if (mapping) {
try {
mapping = JSON.parse(mapping);
mapping = mapping['data'];
} catch (error) {
logError('Failed to parse translation mapping file');
}
if (bid.meta) {
bid.meta.adServerCatId = (bid.meta.iabSubCatId && mapping[adServerInUse] && mapping[adServerInUse]['mapping']) ? mapping[adServerInUse]['mapping'][bid.meta.iabSubCatId] : undefined;
}
} else {
logError('Translation mapping data not found in local storage');
}
}
fn.call(this, adUnitCode, bid);
}

export function initTranslation(...args) {
hooks['addBidResponse'].before(getAdserverCategoryHook, 50);
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is hook add part needs to be checked if it's already been setup. When using the setConfig option, this function is being setup twice.

let url = DEFAULT_TRANSLATION_FILE_URL;
let localStorageKey = DEFAULT_IAB_TO_FW_MAPPING_KEY;
if (args && args.length > 0) {
// use publisher defined translation file
url = args[0];
localStorageKey = DEFAULT_IAB_TO_FW_MAPPING_KEY_PUB;
}

let mappingData = getDataFromLocalStorage(localStorageKey);
if (!mappingData || timestamp() < mappingData.lastUpdated + refreshInDays * 24 * 60 * 60 * 1000) {
ajax(url,
{
success: (response) => {
try {
response = JSON.parse(response);
let mapping = {
lastUpdated: timestamp(),
data: response
}
setDataInLocalStorage(localStorageKey, JSON.stringify(mapping));
} catch (error) {
logError('Failed to parse translation mapping file');
}
},
error: () => {
logError('Failed to load brand category translation file.')
}
},
);
}
}

function setConfig(config) {
if (config.translationFile) {
// if publisher has defined the translation file, preload that file here
initTranslation(config.translationFile);
}
}

initTranslation();
config.getConfig('brandCategoryTranslation', config => setConfig(config.brandCategoryTranslation));
65 changes: 64 additions & 1 deletion src/adapters/bidderFactory.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,9 @@ import { isValidVideoBid } from '../video';
import CONSTANTS from '../constants.json';
import events from '../events';
import includes from 'core-js/library/fn/array/includes';
import { logWarn, logError, parseQueryStringParameters, delayExecution, parseSizesInput, getBidderRequest } from '../utils';
import { ajax } from '../ajax';
import { logWarn, logError, parseQueryStringParameters, delayExecution, parseSizesInput, getBidderRequest, flatten, uniques, timestamp, setDataInLocalStorage, getDataFromLocalStorage, deepAccess } from '../utils';
import { ADPOD } from '../mediaTypes';

/**
* This file aims to support Adapters during the Prebid 0.x -> 1.x transition.
Expand Down Expand Up @@ -345,6 +347,67 @@ export function newBidder(spec) {
}
}

export function preloadBidderMappingFile(fn, adUnits) {
let adPodBidders = adUnits
.filter((adUnit) => deepAccess(adUnit, 'mediaTypes.video.context') === ADPOD)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we also check if adpod.brandCategoryExclusion is true before loading these files? We only need brand category in that case.

.map((adUnit) => adUnit.bids.map((bid) => bid.bidder))
.reduce(flatten, [])
.filter(uniques);

adPodBidders.forEach(bidder => {
let bidderSpec = adapterManager.getBidAdapter(bidder);
if (bidderSpec.getSpec().getMappingFileInfo) {
let info = bidderSpec.getSpec().getMappingFileInfo();
let key = (info.localStorageKey) ? info.localStorageKey : bidderSpec.getSpec().code;
let mappingData = getDataFromLocalStorage(key);
if (!mappingData || timestamp() < mappingData.lastUpdated + info.refreshInDays * 24 * 60 * 60 * 1000) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

can we make info.refreshInDays optional? If undefined use the module default.

ajax(info.url,
{
success: (response) => {
try {
response = JSON.parse(response);
let mapping = {
lastUpdated: timestamp(),
mapping: response.mapping
}
setDataInLocalStorage(key, JSON.stringify(mapping));
} catch (error) {
logError(`Failed to parse ${bidder} bidder translation mapping file`);
}
},
error: () => {
logError(`Failed to load ${bidder} bidder translation file`)
}
},
);
}
}
});
fn.call(this, adUnits);
}

/**
* Reads the data stored in localstorage and returns iab subcategory
* @param {string} bidderCode bidderCode
* @param {string} category bidders category
*/
export function getIabSubCategory(bidderCode, category) {
let bidderSpec = adapterManager.getBidAdapter(bidderCode);
if (bidderSpec.getSpec().getMappingFileInfo) {
let info = bidderSpec.getSpec().getMappingFileInfo();
let key = (info.localStorageKey) ? info.localStorageKey : bidderSpec.getBidderCode();
let data = getDataFromLocalStorage(key);
if (data) {
try {
data = JSON.parse(data);
} catch (error) {
logError(`Failed to parse ${bidderCode} mapping data stored in local storage`);
}
return (data.mapping[category]) ? data.mapping[category] : null;
}
}
}

// check that the bid has a width and height set
function validBidSize(adUnitCode, bid, bidRequests) {
if ((bid.width || bid.width === 0) && (bid.height || bid.height === 0)) {
Expand Down
5 changes: 4 additions & 1 deletion src/prebid.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,13 @@ import { loadScript } from './adloader';
import { config } from './config';
import { auctionManager } from './auctionManager';
import { targeting, getHighestCpmBidsFromBidPool } from './targeting';
import { hook } from './hook';
import { hook, hooks } from './hook';
import { sessionLoader } from './debugging';
import includes from 'core-js/library/fn/array/includes';
import { adunitCounter } from './adUnits';
import { isRendererRequired, executeRenderer } from './Renderer';
import { createBid } from './bidfactory';
import { preloadBidderMappingFile } from './adapters/bidderFactory';

const $$PREBID_GLOBAL$$ = getGlobal();
const CONSTANTS = require('./constants.json');
Expand Down Expand Up @@ -125,6 +126,8 @@ const checkAdUnitSetup = hook('sync', function (adUnits) {
return adUnits;
}, 'checkAdUnitSetup');

hooks['checkAdUnitSetup'].before(preloadBidderMappingFile);

/// ///////////////////////////////
// //
// Start Public APIs //
Expand Down
16 changes: 16 additions & 0 deletions src/utils.js
Original file line number Diff line number Diff line change
Expand Up @@ -1187,6 +1187,22 @@ export function convertTypes(types, params) {
return params;
}

export function setDataInLocalStorage(key, value) {
if (hasLocalStorage()) {
window.localStorage.setItem(key, value);
}
}

export function getDataFromLocalStorage(key) {
if (hasLocalStorage()) {
return window.localStorage.getItem(key);
}
}

export function hasLocalStorage() {
return !!window.localStorage;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

wrap in try/catch. If localstorage is disabled it will throw.

}

export function isArrayOfNums(val, size) {
return (isArray(val)) && ((size) ? val.length === size : true) && (val.every(v => isInteger(v)));
}
76 changes: 76 additions & 0 deletions test/spec/modules/categoryTranslation_spec.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import { getAdserverCategoryHook, initTranslation } from 'modules/categoryTranslation';
import { config } from 'src/config';
import * as utils from 'src/utils';
import { expect } from 'chai';
import { hooks } from 'src/hook';

describe('category translation', function () {
let fakeTranslationServer;
let getLocalStorageStub;

beforeEach(function () {
fakeTranslationServer = sinon.fakeServer.create();
getLocalStorageStub = sinon.stub(utils, 'getDataFromLocalStorage');
});

afterEach(function() {
getLocalStorageStub.restore();
config.resetConfig();
});

it('should translate iab category to adserver category', function () {
hooks['registerAdserver'].before(notifyTranslationModule);
function notifyTranslationModule(fn) {
fn.call(this, 'freewheel');
}

getLocalStorageStub.returns(JSON.stringify({
'data': {
'freewheel': {
mapping: {
'iab-1': '1'
}
}
}
}));
let bid = {
meta: {
iabSubCatId: 'iab-1'
}
}
getAdserverCategoryHook(sinon.spy(), 'code', bid);
expect(bid.meta.adServerCatId).to.equal('1');
});

it('should not make ajax call to update mapping file if data found in localstorage and is not expired', function () {
let clock = sinon.useFakeTimers(utils.timestamp());
getLocalStorageStub.returns(JSON.stringify({
lastUpdated: utils.timestamp(),
mapping: {
'iab-1': '1'
}
}));
initTranslation();
expect(fakeTranslationServer.requests.length).to.equal(0);
clock.restore();
});

it('should use default mapping file if publisher has not defined in config', function () {
getLocalStorageStub.returns(null);
initTranslation();
expect(fakeTranslationServer.requests.length).to.equal(1);
expect(fakeTranslationServer.requests[0].url).to.equal('https://api.myjson.com/bins/j5d0k');
});

it('should use publisher defined defined mapping file', function () {
config.setConfig({
'brandCategoryTranslation': {
'translationFile': 'http://sample.com'
}
});
getLocalStorageStub.returns(null);
initTranslation();
expect(fakeTranslationServer.requests.length).to.equal(2);
expect(fakeTranslationServer.requests[0].url).to.equal('http://sample.com');
});
});
Loading