UNPKG

mk9-prebid

Version:

Header Bidding Management Library

660 lines (586 loc) 25.1 kB
import { uniques, isGptPubadsDefined, getHighestCpm, getOldestHighestCpmBid, groupBy, isAdUnitCodeMatchingSlot, timestamp, deepAccess, deepClone, logError, logWarn, logInfo } from './utils.js'; import { config } from './config.js'; import { NATIVE_TARGETING_KEYS } from './native.js'; import { auctionManager } from './auctionManager.js'; import { sizeSupported } from './sizeMapping.js'; import { ADPOD } from './mediaTypes.js'; import { hook } from './hook.js'; import includes from 'core-js-pure/features/array/includes.js'; import find from 'core-js-pure/features/array/find.js'; const utils = require('./utils.js'); var CONSTANTS = require('./constants.json'); var pbTargetingKeys = []; const MAX_DFP_KEYLENGTH = 20; const TTL_BUFFER = 1000; export const TARGETING_KEYS = Object.keys(CONSTANTS.TARGETING_KEYS).map( key => CONSTANTS.TARGETING_KEYS[key] ); // return unexpired bids const isBidNotExpired = (bid) => (bid.responseTimestamp + bid.ttl * 1000 - TTL_BUFFER) > timestamp(); // return bids whose status is not set. Winning bids can only have a status of `rendered`. const isUnusedBid = (bid) => bid && ((bid.status && !includes([CONSTANTS.BID_STATUS.RENDERED], bid.status)) || !bid.status); export let filters = { isBidNotExpired, isUnusedBid }; // If two bids are found for same adUnitCode, we will use the highest one to take part in auction // This can happen in case of concurrent auctions // If adUnitBidLimit is set above 0 return top N number of bids export const getHighestCpmBidsFromBidPool = hook('sync', function(bidsReceived, highestCpmCallback, adUnitBidLimit = 0, hasModified = false) { if (!hasModified) { const bids = []; const dealPrioritization = config.getConfig('sendBidsControl.dealPrioritization'); // bucket by adUnitcode let buckets = groupBy(bidsReceived, 'adUnitCode'); // filter top bid for each bucket by bidder Object.keys(buckets).forEach(bucketKey => { let bucketBids = []; let bidsByBidder = groupBy(buckets[bucketKey], 'bidderCode'); Object.keys(bidsByBidder).forEach(key => bucketBids.push(bidsByBidder[key].reduce(highestCpmCallback))); // if adUnitBidLimit is set, pass top N number bids if (adUnitBidLimit > 0) { bucketBids = dealPrioritization ? bucketBids.sort(sortByDealAndPriceBucketOrCpm(true)) : bucketBids.sort((a, b) => b.cpm - a.cpm); bids.push(...bucketBids.slice(0, adUnitBidLimit)); } else { bids.push(...bucketBids); } }); return bids; } return bidsReceived; }) /** * A descending sort function that will sort the list of objects based on the following two dimensions: * - bids with a deal are sorted before bids w/o a deal * - then sort bids in each grouping based on the hb_pb value * eg: the following list of bids would be sorted like: * [{ * "hb_adid": "vwx", * "hb_pb": "28", * "hb_deal": "7747" * }, { * "hb_adid": "jkl", * "hb_pb": "10", * "hb_deal": "9234" * }, { * "hb_adid": "stu", * "hb_pb": "50" * }, { * "hb_adid": "def", * "hb_pb": "2" * }] */ export function sortByDealAndPriceBucketOrCpm(useCpm = false) { return function(a, b) { if (a.adserverTargeting.hb_deal !== undefined && b.adserverTargeting.hb_deal === undefined) { return -1; } if ((a.adserverTargeting.hb_deal === undefined && b.adserverTargeting.hb_deal !== undefined)) { return 1; } // assuming both values either have a deal or don't have a deal - sort by the hb_pb param if (useCpm) { return b.cpm - a.cpm; } return b.adserverTargeting.hb_pb - a.adserverTargeting.hb_pb; } } /** * @typedef {Object.<string,string>} targeting * @property {string} targeting_key */ /** * @typedef {Object.<string,Object.<string,string[]>[]>[]} targetingArray */ export function newTargeting(auctionManager) { let targeting = {}; let latestAuctionForAdUnit = {}; targeting.setLatestAuctionForAdUnit = function(adUnitCode, auctionId) { latestAuctionForAdUnit[adUnitCode] = auctionId; }; targeting.resetPresetTargeting = function(adUnitCode, customSlotMatching) { if (isGptPubadsDefined()) { const adUnitCodes = getAdUnitCodes(adUnitCode); const adUnits = auctionManager.getAdUnits().filter(adUnit => includes(adUnitCodes, adUnit.code)); window.googletag.pubads().getSlots().forEach(slot => { let customSlotMatchingFunc = utils.isFn(customSlotMatching) && customSlotMatching(slot); pbTargetingKeys.forEach(function(key) { // reset only registered adunits adUnits.forEach(function(unit) { if (unit.code === slot.getAdUnitPath() || unit.code === slot.getSlotElementId() || (utils.isFn(customSlotMatchingFunc) && customSlotMatchingFunc(unit.code))) { slot.setTargeting(key, null); } }); }); }); } }; targeting.resetPresetTargetingAST = function(adUnitCode) { const adUnitCodes = getAdUnitCodes(adUnitCode); adUnitCodes.forEach(function(unit) { const astTag = window.apntag.getTag(unit); if (astTag && astTag.keywords) { const currentKeywords = Object.keys(astTag.keywords); const newKeywords = {}; currentKeywords.forEach((key) => { if (!includes(pbTargetingKeys, key.toLowerCase())) { newKeywords[key] = astTag.keywords[key]; } }) window.apntag.modifyTag(unit, { keywords: newKeywords }) } }); }; /** * checks if bid has targeting set and belongs based on matching ad unit codes * @return {boolean} true or false */ function bidShouldBeAddedToTargeting(bid, adUnitCodes) { return bid.adserverTargeting && adUnitCodes && ((utils.isArray(adUnitCodes) && includes(adUnitCodes, bid.adUnitCode)) || (typeof adUnitCodes === 'string' && bid.adUnitCode === adUnitCodes)); }; /** * Returns targeting for any bids which have deals if alwaysIncludeDeals === true */ function getDealBids(adUnitCodes, bidsReceived) { if (config.getConfig('targetingControls.alwaysIncludeDeals') === true) { const standardKeys = TARGETING_KEYS.concat(NATIVE_TARGETING_KEYS); // we only want the top bid from bidders who have multiple entries per ad unit code const bids = getHighestCpmBidsFromBidPool(bidsReceived, getHighestCpm); // populate targeting keys for the remaining bids if they have a dealId return bids.map(bid => { if (bid.dealId && bidShouldBeAddedToTargeting(bid, adUnitCodes)) { return { [bid.adUnitCode]: getTargetingMap(bid, standardKeys.filter( key => typeof bid.adserverTargeting[key] !== 'undefined') ) }; } }).filter(bid => bid); // removes empty elements in array } return []; }; /** * Returns filtered ad server targeting for custom and allowed keys. * @param {targetingArray} targeting * @param {string[]} allowedKeys * @return {targetingArray} filtered targeting */ function getAllowedTargetingKeyValues(targeting, allowedKeys) { const defaultKeyring = Object.assign({}, CONSTANTS.TARGETING_KEYS, CONSTANTS.NATIVE_KEYS); const defaultKeys = Object.keys(defaultKeyring); const keyDispositions = {}; logInfo(`allowTargetingKeys - allowed keys [ ${allowedKeys.map(k => defaultKeyring[k]).join(', ')} ]`); targeting.map(adUnit => { const adUnitCode = Object.keys(adUnit)[0]; const keyring = adUnit[adUnitCode]; const keys = keyring.filter(kvPair => { const key = Object.keys(kvPair)[0]; // check if key is in default keys, if not, it's custom, we won't remove it. const isCustom = defaultKeys.filter(defaultKey => key.indexOf(defaultKeyring[defaultKey]) === 0).length === 0; // check if key explicitly allowed, if not, we'll remove it. const found = isCustom || find(allowedKeys, allowedKey => { const allowedKeyName = defaultKeyring[allowedKey]; // we're looking to see if the key exactly starts with one of our default keys. // (which hopefully means it's not custom) const found = key.indexOf(allowedKeyName) === 0; return found; }); keyDispositions[key] = !found; return found; }); adUnit[adUnitCode] = keys; }); const removedKeys = Object.keys(keyDispositions).filter(d => keyDispositions[d]); logInfo(`allowTargetingKeys - removed keys [ ${removedKeys.join(', ')} ]`); // remove any empty targeting objects, as they're unnecessary. const filteredTargeting = targeting.filter(adUnit => { const adUnitCode = Object.keys(adUnit)[0]; const keyring = adUnit[adUnitCode]; return keyring.length > 0; }); return filteredTargeting } /** * Returns all ad server targeting for all ad units. * @param {string=} adUnitCode * @return {Object.<string,targeting>} targeting */ targeting.getAllTargeting = function(adUnitCode, bidsReceived = getBidsReceived()) { const adUnitCodes = getAdUnitCodes(adUnitCode); // Get targeting for the winning bid. Add targeting for any bids that have // `alwaysUseBid=true`. If sending all bids is enabled, add targeting for losing bids. var targeting = getWinningBidTargeting(adUnitCodes, bidsReceived) .concat(getCustomBidTargeting(adUnitCodes, bidsReceived)) .concat(config.getConfig('enableSendAllBids') ? getBidLandscapeTargeting(adUnitCodes, bidsReceived) : getDealBids(adUnitCodes, bidsReceived)) .concat(getAdUnitTargeting(adUnitCodes)); // store a reference of the targeting keys targeting.map(adUnitCode => { Object.keys(adUnitCode).map(key => { adUnitCode[key].map(targetKey => { if (pbTargetingKeys.indexOf(Object.keys(targetKey)[0]) === -1) { pbTargetingKeys = Object.keys(targetKey).concat(pbTargetingKeys); } }); }); }); const defaultKeys = Object.keys(Object.assign({}, CONSTANTS.DEFAULT_TARGETING_KEYS, CONSTANTS.NATIVE_KEYS)); const allowedKeys = config.getConfig('targetingControls.allowTargetingKeys') || defaultKeys; if (Array.isArray(allowedKeys) && allowedKeys.length > 0) { targeting = getAllowedTargetingKeyValues(targeting, allowedKeys); } targeting = flattenTargeting(targeting); const auctionKeysThreshold = config.getConfig('targetingControls.auctionKeyMaxChars'); if (auctionKeysThreshold) { logInfo(`Detected 'targetingControls.auctionKeyMaxChars' was active for this auction; set with a limit of ${auctionKeysThreshold} characters. Running checks on auction keys...`); targeting = filterTargetingKeys(targeting, auctionKeysThreshold); } // make sure at least there is a entry per adUnit code in the targetingSet so receivers of SET_TARGETING call's can know what ad units are being invoked adUnitCodes.forEach(code => { if (!targeting[code]) { targeting[code] = {}; } }); return targeting; }; // create an encoded string variant based on the keypairs of the provided object // - note this will encode the characters between the keys (ie = and &) function convertKeysToQueryForm(keyMap) { return Object.keys(keyMap).reduce(function (queryString, key) { let encodedKeyPair = `${key}%3d${encodeURIComponent(keyMap[key])}%26`; return queryString += encodedKeyPair; }, ''); } function filterTargetingKeys(targeting, auctionKeysThreshold) { // read each targeting.adUnit object and sort the adUnits into a list of adUnitCodes based on priorization setting (eg CPM) let targetingCopy = deepClone(targeting); let targetingMap = Object.keys(targetingCopy).map(adUnitCode => { return { adUnitCode, adserverTargeting: targetingCopy[adUnitCode] }; }).sort(sortByDealAndPriceBucketOrCpm()); // iterate through the targeting based on above list and transform the keys into the query-equivalent and count characters return targetingMap.reduce(function (accMap, currMap, index, arr) { let adUnitQueryString = convertKeysToQueryForm(currMap.adserverTargeting); // for the last adUnit - trim last encoded ampersand from the converted query string if ((index + 1) === arr.length) { adUnitQueryString = adUnitQueryString.slice(0, -3); } // if under running threshold add to result let code = currMap.adUnitCode; let querySize = adUnitQueryString.length; if (querySize <= auctionKeysThreshold) { auctionKeysThreshold -= querySize; logInfo(`AdUnit '${code}' auction keys comprised of ${querySize} characters. Deducted from running threshold; new limit is ${auctionKeysThreshold}`, targetingCopy[code]); accMap[code] = targetingCopy[code]; } else { logWarn(`The following keys for adUnitCode '${code}' exceeded the current limit of the 'auctionKeyMaxChars' setting.\nThe key-set size was ${querySize}, the current allotted amount was ${auctionKeysThreshold}.\n`, targetingCopy[code]); } if ((index + 1) === arr.length && Object.keys(accMap).length === 0) { logError('No auction targeting keys were permitted due to the setting in setConfig(targetingControls.auctionKeyMaxChars). Please review setup and consider adjusting.'); } return accMap; }, {}); } /** * Converts targeting array and flattens to make it easily iteratable * e.g: Sample input to this function * ``` * [ * { * "div-gpt-ad-1460505748561-0": [{"hb_bidder": ["appnexusAst"]}] * }, * { * "div-gpt-ad-1460505748561-0": [{"hb_bidder_appnexusAs": ["appnexusAst"]}] * } * ] * ``` * Resulting array * ``` * { * "div-gpt-ad-1460505748561-0": { * "hb_bidder": "appnexusAst", * "hb_bidder_appnexusAs": "appnexusAst" * } * } * ``` * * @param {targetingArray} targeting * @return {Object.<string,targeting>} targeting */ function flattenTargeting(targeting) { let targetingObj = targeting.map(targeting => { return { [Object.keys(targeting)[0]]: targeting[Object.keys(targeting)[0]] .map(target => { return { [Object.keys(target)[0]]: target[Object.keys(target)[0]].join(', ') }; }).reduce((p, c) => Object.assign(c, p), {}) }; }).reduce(function (accumulator, targeting) { var key = Object.keys(targeting)[0]; accumulator[key] = Object.assign({}, accumulator[key], targeting[key]); return accumulator; }, {}); return targetingObj; } /** * Sets targeting for DFP * @param {Object.<string,Object.<string,string>>} targetingConfig */ targeting.setTargetingForGPT = function(targetingConfig, customSlotMatching) { window.googletag.pubads().getSlots().forEach(slot => { Object.keys(targetingConfig).filter(customSlotMatching ? customSlotMatching(slot) : isAdUnitCodeMatchingSlot(slot)) .forEach(targetId => Object.keys(targetingConfig[targetId]).forEach(key => { let valueArr = targetingConfig[targetId][key]; if (typeof valueArr === 'string') { valueArr = valueArr.split(','); } valueArr = (valueArr.length > 1) ? [valueArr] : valueArr; valueArr.map((value) => { utils.logMessage(`Attempting to set key value for slot: ${slot.getSlotElementId()} key: ${key} value: ${value}`); return value; }).forEach(value => { slot.setTargeting(key, value); }); }) ) }) }; /** * normlizes input to a `adUnit.code` array * @param {(string|string[])} adUnitCode [description] * @return {string[]} AdUnit code array */ function getAdUnitCodes(adUnitCode) { if (typeof adUnitCode === 'string') { return [adUnitCode]; } else if (utils.isArray(adUnitCode)) { return adUnitCode; } return auctionManager.getAdUnitCodes() || []; } function getBidsReceived() { let bidsReceived = auctionManager.getBidsReceived(); if (!config.getConfig('useBidCache')) { bidsReceived = bidsReceived.filter(bid => latestAuctionForAdUnit[bid.adUnitCode] === bid.auctionId) } bidsReceived = bidsReceived .filter(bid => deepAccess(bid, 'video.context') !== ADPOD) .filter(bid => bid.mediaType !== 'banner' || sizeSupported([bid.width, bid.height])) .filter(filters.isUnusedBid) .filter(filters.isBidNotExpired) ; return getHighestCpmBidsFromBidPool(bidsReceived, getOldestHighestCpmBid); } /** * Returns top bids for a given adUnit or set of adUnits. * @param {(string|string[])} adUnitCode adUnitCode or array of adUnitCodes * @return {[type]} [description] */ targeting.getWinningBids = function(adUnitCode, bidsReceived = getBidsReceived()) { const adUnitCodes = getAdUnitCodes(adUnitCode); return bidsReceived .filter(bid => includes(adUnitCodes, bid.adUnitCode)) .filter(bid => bid.cpm > 0) .map(bid => bid.adUnitCode) .filter(uniques) .map(adUnitCode => bidsReceived .filter(bid => bid.adUnitCode === adUnitCode ? bid : null) .reduce(getHighestCpm)); }; /** * @param {(string|string[])} adUnitCode adUnitCode or array of adUnitCodes * Sets targeting for AST */ targeting.setTargetingForAst = function(adUnitCodes) { let astTargeting = targeting.getAllTargeting(adUnitCodes); try { targeting.resetPresetTargetingAST(adUnitCodes); } catch (e) { utils.logError('unable to reset targeting for AST' + e) } Object.keys(astTargeting).forEach(targetId => Object.keys(astTargeting[targetId]).forEach(key => { utils.logMessage(`Attempting to set targeting for targetId: ${targetId} key: ${key} value: ${astTargeting[targetId][key]}`); // setKeywords supports string and array as value if (utils.isStr(astTargeting[targetId][key]) || utils.isArray(astTargeting[targetId][key])) { let keywordsObj = {}; let regex = /pt[0-9]/; if (key.search(regex) < 0) { keywordsObj[key.toUpperCase()] = astTargeting[targetId][key]; } else { // pt${n} keys should not be uppercased keywordsObj[key] = astTargeting[targetId][key]; } window.apntag.setKeywords(targetId, keywordsObj, { overrideKeyValue: true }); } }) ); }; /** * Get targeting key value pairs for winning bid. * @param {string[]} AdUnit code array * @return {targetingArray} winning bids targeting */ function getWinningBidTargeting(adUnitCodes, bidsReceived) { let winners = targeting.getWinningBids(adUnitCodes, bidsReceived); let standardKeys = getStandardKeys(); winners = winners.map(winner => { return { [winner.adUnitCode]: Object.keys(winner.adserverTargeting) .filter(key => typeof winner.sendStandardTargeting === 'undefined' || winner.sendStandardTargeting || standardKeys.indexOf(key) === -1) .reduce((acc, key) => { const targetingValue = [winner.adserverTargeting[key]]; const targeting = { [key.substring(0, MAX_DFP_KEYLENGTH)]: targetingValue }; if (key === CONSTANTS.TARGETING_KEYS.DEAL) { const bidderCodeTargetingKey = `${key}_${winner.bidderCode}`.substring(0, MAX_DFP_KEYLENGTH); const bidderCodeTargeting = { [bidderCodeTargetingKey]: targetingValue }; return [...acc, targeting, bidderCodeTargeting]; } return [...acc, targeting]; }, []) }; }); return winners; } function getStandardKeys() { return auctionManager.getStandardBidderAdServerTargeting() // in case using a custom standard key set .map(targeting => targeting.key) .concat(TARGETING_KEYS).filter(uniques); // standard keys defined in the library. } /** * Merge custom adserverTargeting with same key name for same adUnitCode. * e.g: Appnexus defining custom keyvalue pair foo:bar and Rubicon defining custom keyvalue pair foo:baz will be merged to foo: ['bar','baz'] * * @param {Object[]} acc Accumulator for reducer. It will store updated bidResponse objects * @param {Object} bid BidResponse * @param {number} index current index * @param {Array} arr original array */ function mergeAdServerTargeting(acc, bid, index, arr) { function concatTargetingValue(key) { return function(currentBidElement) { if (!utils.isArray(currentBidElement.adserverTargeting[key])) { currentBidElement.adserverTargeting[key] = [currentBidElement.adserverTargeting[key]]; } currentBidElement.adserverTargeting[key] = currentBidElement.adserverTargeting[key].concat(bid.adserverTargeting[key]).filter(uniques); delete bid.adserverTargeting[key]; } } function hasSameAdunitCodeAndKey(key) { return function(currentBidElement) { return currentBidElement.adUnitCode === bid.adUnitCode && currentBidElement.adserverTargeting[key] } } Object.keys(bid.adserverTargeting) .filter(getCustomKeys()) .forEach(key => { if (acc.length) { acc.filter(hasSameAdunitCodeAndKey(key)) .forEach(concatTargetingValue(key)); } }); acc.push(bid); return acc; } function getCustomKeys() { let standardKeys = getStandardKeys().concat(NATIVE_TARGETING_KEYS); return function(key) { return standardKeys.indexOf(key) === -1; } } function truncateCustomKeys(bid) { return { [bid.adUnitCode]: Object.keys(bid.adserverTargeting) // Get only the non-standard keys of the losing bids, since we // don't want to override the standard keys of the winning bid. .filter(getCustomKeys()) .map(key => { return { [key.substring(0, MAX_DFP_KEYLENGTH)]: [bid.adserverTargeting[key]] }; }) } } /** * Get custom targeting key value pairs for bids. * @param {string[]} AdUnit code array * @return {targetingArray} bids with custom targeting defined in bidderSettings */ function getCustomBidTargeting(adUnitCodes, bidsReceived) { return bidsReceived .filter(bid => includes(adUnitCodes, bid.adUnitCode)) .map(bid => Object.assign({}, bid)) .reduce(mergeAdServerTargeting, []) .map(truncateCustomKeys) .filter(bid => bid); // removes empty elements in array; } /** * Get targeting key value pairs for non-winning bids. * @param {string[]} AdUnit code array * @return {targetingArray} all non-winning bids targeting */ function getBidLandscapeTargeting(adUnitCodes, bidsReceived) { const standardKeys = TARGETING_KEYS.concat(NATIVE_TARGETING_KEYS); const adUnitBidLimit = config.getConfig('sendBidsControl.bidLimit'); const bids = getHighestCpmBidsFromBidPool(bidsReceived, getHighestCpm, adUnitBidLimit); const allowSendAllBidsTargetingKeys = config.getConfig('targetingControls.allowSendAllBidsTargetingKeys'); const allowedSendAllBidTargeting = allowSendAllBidsTargetingKeys ? allowSendAllBidsTargetingKeys.map((key) => CONSTANTS.TARGETING_KEYS[key]) : standardKeys; // populate targeting keys for the remaining bids return bids.map(bid => { if (bidShouldBeAddedToTargeting(bid, adUnitCodes)) { return { [bid.adUnitCode]: getTargetingMap(bid, standardKeys.filter( key => typeof bid.adserverTargeting[key] !== 'undefined' && allowedSendAllBidTargeting.indexOf(key) !== -1) ) }; } }).filter(bid => bid); // removes empty elements in array } function getTargetingMap(bid, keys) { return keys.map(key => { return { [`${key}_${bid.bidderCode}`.substring(0, MAX_DFP_KEYLENGTH)]: [bid.adserverTargeting[key]] }; }); } function getAdUnitTargeting(adUnitCodes) { function getTargetingObj(adUnit) { return deepAccess(adUnit, CONSTANTS.JSON_MAPPING.ADSERVER_TARGETING); } function getTargetingValues(adUnit) { const aut = getTargetingObj(adUnit); return Object.keys(aut) .map(function(key) { if (utils.isStr(aut[key])) aut[key] = aut[key].split(','); if (!utils.isArray(aut[key])) aut[key] = [ aut[key] ]; return { [key]: aut[key] }; }); } return auctionManager.getAdUnits() .filter(adUnit => includes(adUnitCodes, adUnit.code) && getTargetingObj(adUnit)) .map(adUnit => { return {[adUnit.code]: getTargetingValues(adUnit)} }); } targeting.isApntagDefined = function() { if (window.apntag && utils.isFn(window.apntag.setKeywords)) { return true; } }; return targeting; } export const targeting = newTargeting(auctionManager);