diff --git a/modules/adtrueBidAdapter.js b/modules/adtrueBidAdapter.js new file mode 100644 index 00000000000..bb2c672b761 --- /dev/null +++ b/modules/adtrueBidAdapter.js @@ -0,0 +1,655 @@ +import * as utils from '../src/utils.js'; +import {registerBidder} from '../src/adapters/bidderFactory.js'; +import {BANNER, NATIVE, VIDEO} from "../src/mediaTypes"; +import {config} from "../src/config"; +import {getStorageManager} from "../src/storageManager"; + +const storage = getStorageManager(); + +const BIDDER_CODE = 'adtrue'; +const ADTRUE_CURRENCY = 'USD'; +const ADTRUE_TTL = 120; +const ENDPOINT_URL = 'https://hb.adtrue.com/prebid/auction'; +const LOG_WARN_PREFIX = 'AdTrue: '; + +const DEFAULT_CURRENCY = 'USD'; +const AUCTION_TYPE = 1; +const UNDEFINED = undefined; +const DEFAULT_WIDTH = 0; +const DEFAULT_HEIGHT = 0; +const NET_REVENUE = false; + +const USER_SYNC_URL_IFRAME = 'https://hb.adtrue.com/prebid/usersync?t=iframe&p='; +const USER_SYNC_URL_IMAGE = 'https://hb.adtrue.com/prebid/usersync?t=img&p='; + +let publisherId = 0; + +let NATIVE_ASSET_ID_TO_KEY_MAP = {}; +let NATIVE_ASSET_KEY_TO_ASSET_MAP = {}; + +const NATIVE_ASSETS = { + 'TITLE': {ID: 1, KEY: 'title', TYPE: 0}, + 'IMAGE': {ID: 2, KEY: 'image', TYPE: 0}, + 'ICON': {ID: 3, KEY: 'icon', TYPE: 0}, + 'SPONSOREDBY': {ID: 4, KEY: 'sponsoredBy', TYPE: 1}, // please note that type of SPONSORED is also 1 + 'BODY': {ID: 5, KEY: 'body', TYPE: 2}, // please note that type of DESC is also set to 2 + 'CLICKURL': {ID: 6, KEY: 'clickUrl', TYPE: 0}, + 'VIDEO': {ID: 7, KEY: 'video', TYPE: 0}, + 'EXT': {ID: 8, KEY: 'ext', TYPE: 0}, + 'DATA': {ID: 9, KEY: 'data', TYPE: 0}, + 'LOGO': {ID: 10, KEY: 'logo', TYPE: 0}, + 'SPONSORED': {ID: 11, KEY: 'sponsored', TYPE: 1}, // please note that type of SPONSOREDBY is also set to 1 + 'DESC': {ID: 12, KEY: 'data', TYPE: 2}, // please note that type of BODY is also set to 2 + 'RATING': {ID: 13, KEY: 'rating', TYPE: 3}, + 'LIKES': {ID: 14, KEY: 'likes', TYPE: 4}, + 'DOWNLOADS': {ID: 15, KEY: 'downloads', TYPE: 5}, + 'PRICE': {ID: 16, KEY: 'price', TYPE: 6}, + 'SALEPRICE': {ID: 17, KEY: 'saleprice', TYPE: 7}, + 'PHONE': {ID: 18, KEY: 'phone', TYPE: 8}, + 'ADDRESS': {ID: 19, KEY: 'address', TYPE: 9}, + 'DESC2': {ID: 20, KEY: 'desc2', TYPE: 10}, + 'DISPLAYURL': {ID: 21, KEY: 'displayurl', TYPE: 11}, + 'CTA': {ID: 22, KEY: 'cta', TYPE: 12} +}; + +const NATIVE_ASSET_IMAGE_TYPE = { + 'ICON': 1, + 'LOGO': 2, + 'IMAGE': 3 +} + +// check if title, image can be added with mandatory field default values +const NATIVE_MINIMUM_REQUIRED_IMAGE_ASSETS = [ + { + id: NATIVE_ASSETS.SPONSOREDBY.ID, + required: true, + data: { + type: 1 + } + }, + { + id: NATIVE_ASSETS.TITLE.ID, + required: true, + }, + { + id: NATIVE_ASSETS.IMAGE.ID, + required: true, + } +] + + +function _getDomainFromURL(url) { + let anchor = document.createElement('a'); + anchor.href = url; + return anchor.hostname; +} + +function _parseSlotParam(paramName, paramValue) { + switch (paramName) { + case 'reserve': + return parseFloat(paramValue) || UNDEFINED; + default: + return paramValue; + } +} + +let platform = (function getPlatform() { + var ua = navigator.userAgent; + if (ua.indexOf('Android') > -1 || ua.indexOf('Adr') > -1) { + return 'Android' + } + if (ua.match(/\(i[^;]+;( U;)? CPU.+Mac OS X/)) { + return 'iOS' + } + return 'windows' +})(); + +function _generateGUID() { + var d = new Date().getTime(); + var guid = 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function (c) { + var r = (d + Math.random() * 16) % 16 | 0; + d = Math.floor(d / 16); + return (c == 'x' ? r : (r & 0x3 | 0x8)).toString(16); + }) + return guid; +} + +function _isMobile() { + return (/(ios|ipod|ipad|iphone|android)/i).test(navigator.userAgent); +} + +function _isConnectedTV() { + return (/(smart[-]?tv|hbbtv|appletv|googletv|hdmi|netcast\.tv|viera|nettv|roku|\bdtv\b|sonydtv|inettvbrowser|\btv\b)/i).test(navigator.userAgent); +} + + +function _parseAdSlot(bid) { + bid.params.width = 0; + bid.params.height = 0; + // check if size is mentioned in sizes array. in that case do not check for @ in adslot + if (bid.hasOwnProperty('mediaTypes') && + bid.mediaTypes.hasOwnProperty(BANNER) && + bid.mediaTypes.banner.hasOwnProperty('sizes')) { + var i = 0; + var sizeArray = []; + for (; i < bid.mediaTypes.banner.sizes.length; i++) { + if (bid.mediaTypes.banner.sizes[i].length === 2) { // sizes[i].length will not be 2 in case where size is set as fluid, we want to skip that entry + sizeArray.push(bid.mediaTypes.banner.sizes[i]); + } + } + bid.mediaTypes.banner.sizes = sizeArray; + if (bid.mediaTypes.banner.sizes.length >= 1) { + // set the first size in sizes array in bid.params.width and bid.params.height. These will be sent as primary size. + // The rest of the sizes will be sent in format array. + bid.params.width = bid.mediaTypes.banner.sizes[0][0]; + bid.params.height = bid.mediaTypes.banner.sizes[0][1]; + bid.mediaTypes.banner.sizes = bid.mediaTypes.banner.sizes.splice(1, bid.mediaTypes.banner.sizes.length - 1); + } + } +} + +function _initConf(refererInfo) { + return { + pageURL: (refererInfo && refererInfo.referer) ? refererInfo.referer : window.location.href, + refURL: window.document.referrer + }; +} + +function _getLanguage() { + const language = navigator.language ? 'language' : 'userLanguage'; + return navigator[language].split('-')[0]; +} + +function _createOrtbTemplate(conf) { + var guid; + if (storage.getDataFromLocalStorage('adtrue_user_id') == null) { + storage.setDataInLocalStorage('adtrue_user_id', _generateGUID()) + } + guid = storage.getDataFromLocalStorage('adtrue_user_id') + + return { + id: '' + new Date().getTime(), + at: AUCTION_TYPE, + cur: [DEFAULT_CURRENCY], + imp: [], + site: { + page: conf.pageURL, + ref: conf.refURL, + publisher: {} + }, + device: { + ip: '', + ua: navigator.userAgent, + os: platform, + js: 1, + dnt: (navigator.doNotTrack == 'yes' || navigator.doNotTrack == '1' || navigator.msDoNotTrack == '1') ? 1 : 0, + h: screen.height, + w: screen.width, + language: _getLanguage(), + devicetype: _isMobile() ? 1 : _isConnectedTV() ? 3 : 2, + geo: { + country: '{country_code}', + type: 0, + ipservice: 1, + region: '', + city: '', + }, + }, + user: { + id: guid + }, + ext: {} + }; +} + +function _checkParamDataType(key, value, datatype) { + var errMsg = 'Ignoring param key: ' + key + ', expects ' + datatype + ', found ' + typeof value; + var functionToExecute; + switch (datatype) { + case DATA_TYPES.BOOLEAN: + functionToExecute = utils.isBoolean; + break; + case DATA_TYPES.NUMBER: + functionToExecute = utils.isNumber; + break; + case DATA_TYPES.STRING: + functionToExecute = utils.isStr; + break; + case DATA_TYPES.ARRAY: + functionToExecute = utils.isArray; + break; + } + if (functionToExecute(value)) { + return value; + } + utils.logWarn(LOG_WARN_PREFIX + errMsg); + return UNDEFINED; +} + +function _parseNativeResponse(bid, newBid) { + newBid.native = {}; + if (bid.hasOwnProperty('adm')) { + var adm = ''; + try { + adm = JSON.parse(bid.adm.replace(/\\/g, '')); + } catch (ex) { + // utils.logWarn(LOG_WARN_PREFIX + 'Error: Cannot parse native reponse for ad response: ' + newBid.adm); + return; + } + if (adm && adm.native && adm.native.assets && adm.native.assets.length > 0) { + newBid.mediaType = NATIVE; + for (let i = 0, len = adm.native.assets.length; i < len; i++) { + switch (adm.native.assets[i].id) { + case NATIVE_ASSETS.TITLE.ID: + newBid.native.title = adm.native.assets[i].title && adm.native.assets[i].title.text; + break; + case NATIVE_ASSETS.IMAGE.ID: + newBid.native.image = { + url: adm.native.assets[i].img && adm.native.assets[i].img.url, + height: adm.native.assets[i].img && adm.native.assets[i].img.h, + width: adm.native.assets[i].img && adm.native.assets[i].img.w, + }; + break; + case NATIVE_ASSETS.ICON.ID: + newBid.native.icon = { + url: adm.native.assets[i].img && adm.native.assets[i].img.url, + height: adm.native.assets[i].img && adm.native.assets[i].img.h, + width: adm.native.assets[i].img && adm.native.assets[i].img.w, + }; + break; + case NATIVE_ASSETS.SPONSOREDBY.ID: + case NATIVE_ASSETS.BODY.ID: + case NATIVE_ASSETS.LIKES.ID: + case NATIVE_ASSETS.DOWNLOADS.ID: + case NATIVE_ASSETS.PRICE: + case NATIVE_ASSETS.SALEPRICE.ID: + case NATIVE_ASSETS.PHONE.ID: + case NATIVE_ASSETS.ADDRESS.ID: + case NATIVE_ASSETS.DESC2.ID: + case NATIVE_ASSETS.CTA.ID: + case NATIVE_ASSETS.RATING.ID: + case NATIVE_ASSETS.DISPLAYURL.ID: + newBid.native[NATIVE_ASSET_ID_TO_KEY_MAP[adm.native.assets[i].id]] = adm.native.assets[i].data && adm.native.assets[i].data.value; + break; + } + } + newBid.native.clickUrl = adm.native.link && adm.native.link.url; + newBid.native.clickTrackers = (adm.native.link && adm.native.link.clicktrackers) || []; + newBid.native.impressionTrackers = adm.native.imptrackers || []; + newBid.native.jstracker = adm.native.jstracker || []; + if (!newBid.width) { + newBid.width = DEFAULT_WIDTH; + } + if (!newBid.height) { + newBid.height = DEFAULT_HEIGHT; + } + } + } +} + + +function _createBannerRequest(bid) { + var sizes = bid.mediaTypes.banner.sizes; + var format = []; + var bannerObj; + if (sizes !== UNDEFINED && utils.isArray(sizes)) { + bannerObj = {}; + if (!bid.params.width && !bid.params.height) { + if (sizes.length === 0) { + // i.e. since bid.params does not have width or height, and length of sizes is 0, need to ignore this banner imp + bannerObj = UNDEFINED; + utils.logWarn(LOG_WARN_PREFIX + 'Error: mediaTypes.banner.size missing for adunit: ' + bid.params.adUnit + '. Ignoring the banner impression in the adunit.'); + return bannerObj; + } else { + bannerObj.w = parseInt(sizes[0][0], 10); + bannerObj.h = parseInt(sizes[0][1], 10); + sizes = sizes.splice(1, sizes.length - 1); + } + } else { + bannerObj.w = bid.params.width; + bannerObj.h = bid.params.height; + } + if (sizes.length > 0) { + format = []; + sizes.forEach(function (size) { + if (size.length > 1) { + format.push({w: size[0], h: size[1]}); + } + }); + if (format.length > 0) { + bannerObj.format = format; + } + } + bannerObj.pos = 0; + bannerObj.topframe = utils.inIframe() ? 0 : 1; + } else { + utils.logWarn(LOG_WARN_PREFIX + 'Error: mediaTypes.banner.size missing for adunit: ' + bid.params.adUnit + '. Ignoring the banner impression in the adunit.'); + bannerObj = UNDEFINED; + } + return bannerObj; +} + +function _createVideoRequest(bid) { + var videoData = bid.params.video; + var videoObj; + + if (videoData !== UNDEFINED) { + videoObj = {}; + for (var key in VIDEO_CUSTOM_PARAMS) { + if (videoData.hasOwnProperty(key)) { + videoObj[key] = _checkParamDataType(key, videoData[key], VIDEO_CUSTOM_PARAMS[key]); + } + } + // read playersize and assign to h and w. + if (utils.isArray(bid.mediaTypes.video.playerSize[0])) { + videoObj.w = parseInt(bid.mediaTypes.video.playerSize[0][0], 10); + videoObj.h = parseInt(bid.mediaTypes.video.playerSize[0][1], 10); + } else if (utils.isNumber(bid.mediaTypes.video.playerSize[0])) { + videoObj.w = parseInt(bid.mediaTypes.video.playerSize[0], 10); + videoObj.h = parseInt(bid.mediaTypes.video.playerSize[1], 10); + } + if (bid.params.video.hasOwnProperty('skippable')) { + videoObj.ext = { + 'video_skippable': bid.params.video.skippable ? 1 : 0 + }; + } + } else { + videoObj = UNDEFINED; + utils.logWarn(LOG_WARN_PREFIX + 'Error: Video config params missing for adunit: ' + bid.params.adUnit + ' with mediaType set as video. Ignoring video impression in the adunit.'); + } + return videoObj; +} + +function _checkMediaType(adm, newBid) { + var admStr = ''; + var videoRegex = new RegExp(/VAST\s+version/); + newBid.mediaType = BANNER; + if (videoRegex.test(adm)) { + newBid.mediaType = VIDEO; + } else { + try { + admStr = JSON.parse(adm.replace(/\\/g, '')); + if (admStr && admStr.native) { + newBid.mediaType = NATIVE; + } + } catch (e) { + utils.logWarn(LOG_WARN_PREFIX + 'Error: Cannot parse native reponse for ad response: ' + adm); + } + } +} + +function _createImpressionObject(bid, conf) { + var impObj = {}; + var bannerObj; + var videoObj; + var sizes = bid.hasOwnProperty('sizes') ? bid.sizes : []; + var mediaTypes = ''; + var format = []; + + impObj = { + id: bid.bidId, + tagid: String(bid.params.zoneId || undefined), + bidfloor: _parseSlotParam('reserve', bid.params.reserve), + secure: 1, + ext: {}, + bidfloorcur: DEFAULT_CURRENCY + }; + + if (bid.hasOwnProperty('mediaTypes')) { + for (mediaTypes in bid.mediaTypes) { + switch (mediaTypes) { + case BANNER: + bannerObj = _createBannerRequest(bid); + if (bannerObj !== UNDEFINED) { + impObj.banner = bannerObj; + } + break; + case VIDEO: + videoObj = _createVideoRequest(bid); + if (videoObj !== UNDEFINED) { + impObj.video = videoObj; + } + break; + } + } + } else { + // mediaTypes is not present, so this is a banner only impression + // this part of code is required for older testcases with no 'mediaTypes' to run succesfully. + bannerObj = { + pos: 0, + w: bid.params.width, + h: bid.params.height, + topframe: utils.inIframe() ? 0 : 1 + }; + if (utils.isArray(sizes) && sizes.length > 1) { + sizes = sizes.splice(1, sizes.length - 1); + sizes.forEach(size => { + format.push({ + w: size[0], + h: size[1] + }); + }); + bannerObj.format = format; + } + impObj.banner = bannerObj; + } + + return impObj.hasOwnProperty(BANNER) || + impObj.hasOwnProperty(NATIVE) || + impObj.hasOwnProperty(VIDEO) ? impObj : UNDEFINED; +} + + +export const spec = { + code: BIDDER_CODE, + supportedMediaTypes: ['banner', 'video'], + + isBidRequestValid: function (bid) { + if (bid && bid.params) { + if (!bid.params.zoneId) { + utils.logWarn(LOG_WARN_PREFIX + 'Error: missing zoneId'); + return false; + } + if (!bid.params.publisherId) { + utils.logWarn(LOG_WARN_PREFIX + 'Error: missing publisherId'); + return false; + } + return true; + } + return false; + }, + + buildRequests: function (validBidRequests, bidderRequest) { + let refererInfo; + if (bidderRequest && bidderRequest.refererInfo) { + refererInfo = bidderRequest.refererInfo; + } + let conf = _initConf(refererInfo); + let payload = _createOrtbTemplate(conf); + let bidCurrency = ''; + let dctrArr = []; + let bid; + let blockedIabCategories = []; + + validBidRequests.forEach(originalBid => { + bid = utils.deepClone(originalBid); + _parseAdSlot(bid); + + conf.zoneId = conf.zoneId || bid.params.zoneId; + conf.pubId = conf.pubId || bid.params.publisherId; + + conf.transactionId = bid.transactionId; + if (bidCurrency === '') { + bidCurrency = bid.params.currency || UNDEFINED; + } else if (bid.params.hasOwnProperty('currency') && bidCurrency !== bid.params.currency) { + utils.logWarn(LOG_WARN_PREFIX + 'Currency specifier ignored. Only one currency permitted.'); + } + bid.params.currency = bidCurrency; + + var impObj = _createImpressionObject(bid, conf); + if (impObj) { + payload.imp.push(impObj); + } + }); + if (payload.imp.length == 0) { + return; + } + publisherId = conf.pubId.trim(); + + payload.site.publisher.id = conf.pubId.trim(); + payload.ext.wrapper = {}; + + payload.ext.wrapper.wv = $$REPO_AND_VERSION$$; + payload.ext.wrapper.transactionId = conf.transactionId; + payload.ext.wrapper.wiid = conf.wiid || bidderRequest.auctionId; + payload.ext.wrapper.wp = 'pbjs'; + + payload.user.geo = {}; + payload.device.geo = payload.user.geo; + payload.site.page = conf.pageURL; + payload.site.domain = _getDomainFromURL(payload.site.page); + + if (typeof config.getConfig('content') === 'object') { + payload.site.content = config.getConfig('content'); + } + + if (typeof config.getConfig('device') === 'object') { + payload.device = Object.assign(payload.device, config.getConfig('device')); + } + + utils.deepSetValue(payload, 'source.tid', conf.transactionId); + + // test bids + if (window.location.href.indexOf('adtrueTest=true') !== -1) { + payload.test = 1; + } + // adding schain object + if (validBidRequests[0].schain) { + utils.deepSetValue(payload, 'source.ext.schain', validBidRequests[0].schain); + } + + // Attaching GDPR Consent Params + if (bidderRequest && bidderRequest.gdprConsent) { + utils.deepSetValue(payload, 'user.ext.consent', bidderRequest.gdprConsent.consentString); + utils.deepSetValue(payload, 'regs.ext.gdpr', (bidderRequest.gdprConsent.gdprApplies ? 1 : 0)); + } + + // CCPA + if (bidderRequest && bidderRequest.uspConsent) { + utils.deepSetValue(payload, 'regs.ext.us_privacy', bidderRequest.uspConsent); + } + + // coppa compliance + if (config.getConfig('coppa') === true) { + utils.deepSetValue(payload, 'regs.coppa', 1); + } + + return { + method: 'POST', + url: ENDPOINT_URL, + data: JSON.stringify(payload), + bidderRequests: bidderRequest + }; + }, + interpretResponse: function (serverResponses, bidderRequest) { + const bidResponses = []; + var respCur = DEFAULT_CURRENCY; + let parsedRequest = JSON.parse(bidderRequest.data); + let parsedReferrer = parsedRequest.site && parsedRequest.site.ref ? parsedRequest.site.ref : ''; + try { + if (serverResponses.body && serverResponses.body.seatbid && utils.isArray(serverResponses.body.seatbid)) { + // Supporting multiple bid responses for same adSize + respCur = serverResponses.body.cur || respCur; + serverResponses.body.seatbid.forEach(seatbidder => { + seatbidder.bid && + utils.isArray(seatbidder.bid) && + seatbidder.bid.forEach(bid => { + let newBid = { + requestId: bid.impid, + cpm: (parseFloat(bid.price) || 0).toFixed(2), + width: bid.w, + height: bid.h, + creativeId: bid.crid || bid.id, + dealId: bid.dealid, + currency: respCur, + netRevenue: NET_REVENUE, + ttl: 300, + referrer: parsedReferrer, + ad: bid.adm, + partnerImpId: bid.id || '' // partner impression Id + }; + if (parsedRequest.imp && parsedRequest.imp.length > 0) { + parsedRequest.imp.forEach(req => { + if (bid.impid === req.id) { + _checkMediaType(bid.adm, newBid); + switch (newBid.mediaType) { + case BANNER: + break; + case VIDEO: + break; + case NATIVE: + _parseNativeResponse(bid, newBid); + break; + } + } + }); + } + + newBid.meta = {}; + if (bid.ext && bid.ext.dspid) { + newBid.meta.networkId = bid.ext.dspid; + } + if (bid.ext && bid.ext.advid) { + newBid.meta.buyerId = bid.ext.advid; + } + if (bid.adomain && bid.adomain.length > 0) { + newBid.meta.advertiserDomains = bid.adomain; + newBid.meta.clickUrl = bid.adomain[0]; + } + + // adserverTargeting + if (seatbidder.ext && seatbidder.ext.buyid) { + newBid.adserverTargeting = { + 'hb_buyid_adtrue': seatbidder.ext.buyid + }; + } + + bidResponses.push(newBid); + }); + }); + } + } catch (error) { + utils.logError(error); + } + return bidResponses; + }, + getUserSyncs: function (syncOptions, responses, gdprConsent, uspConsent) { + let syncurl = '' + publisherId; + + if (gdprConsent) { + syncurl += '&gdpr=' + (gdprConsent.gdprApplies ? 1 : 0); + syncurl += '&gdpr_consent=' + encodeURIComponent(gdprConsent.consentString || ''); + } + if (uspConsent) { + syncurl += '&us_privacy=' + encodeURIComponent(uspConsent); + } + + // coppa compliance + if (config.getConfig('coppa') === true) { + syncurl += '&coppa=1'; + } + + if (syncOptions.iframeEnabled) { + return [{ + type: 'iframe', + url: USER_SYNC_URL_IFRAME + syncurl + }]; + } else { + return [{ + type: 'image', + url: USER_SYNC_URL_IMAGE + syncurl + }]; + } + } +}; +registerBidder(spec); diff --git a/modules/adtrueBidAdapter.md b/modules/adtrueBidAdapter.md new file mode 100644 index 00000000000..6d0c6f2c125 --- /dev/null +++ b/modules/adtrueBidAdapter.md @@ -0,0 +1,36 @@ +# Overview + +``` +Module Name: AdTrue Bid Adapter +Module Type: Bidder Adapter +Maintainer: ssp@adtrue.com +``` + +# Description + +Connects to AdTrue exchange for bids. +AdTrue bid adapter supports Banner currently. + +# Test Parameters +``` +var adUnits = [ + { + code: 'banner-div', + mediaTypes: { + banner: { + sizes: [[300, 250]] + } + }, + bids: [ + { + bidder: 'adtrue', + params: { + zoneId: '21423', // required, Zone Id provided by AdTrue + publisherId: '1491', // required, Publisher Id provided by AdTrue + reserve: 0.1 // optional + } + } + ] + } +]; +```