diff --git a/modules/videoheroesBidAdapter.js b/modules/videoheroesBidAdapter.js index ee2c2deef8b..fa81adf68bd 100644 --- a/modules/videoheroesBidAdapter.js +++ b/modules/videoheroesBidAdapter.js @@ -1,261 +1,358 @@ -import { isEmpty, parseUrl, isStr, triggerPixel } from '../src/utils.js'; -import { registerBidder } from '../src/adapters/bidderFactory.js'; +import { isEmpty, parseUrl, isPlainObject, isArray, isArrayOfNums, isFn, isStr, triggerPixel } from '../src/utils.js'; import { BANNER, NATIVE, VIDEO } from '../src/mediaTypes.js'; +import { registerBidder } from '../src/adapters/bidderFactory.js'; import { config } from '../src/config.js'; import { convertOrtbRequestToProprietaryNative } from '../src/native.js'; -/** - * @typedef {import('../src/adapters/bidderFactory.js').BidRequest} BidRequest - * @typedef {import('../src/adapters/bidderFactory.js').Bid} Bid - */ - const BIDDER_CODE = 'videoheroes'; -const DEFAULT_CUR = 'USD'; -const ENDPOINT_URL = `https://point.contextualadv.com/?t=2&partner=hash`; - -const NATIVE_ASSETS_IDS = { 1: 'title', 2: 'icon', 3: 'image', 4: 'body', 5: 'sponsoredBy', 6: 'cta' }; -const NATIVE_ASSETS = { - title: { id: 1, name: 'title' }, - icon: { id: 2, type: 1, name: 'img' }, - image: { id: 3, type: 3, name: 'img' }, - body: { id: 4, type: 2, name: 'data' }, - sponsoredBy: { id: 5, type: 1, name: 'data' }, - cta: { id: 6, type: 12, name: 'data' } -}; +const MEDIA_TYPES = [BANNER, VIDEO, NATIVE]; +const DEF_FLOOR = 0.05; +const CUR = 'USD'; +const TTL = 1200; +const ENDPOINT_URL = `https://point.contextualadv.com/?t=2&partner=`; export const spec = { code: BIDDER_CODE, - supportedMediaTypes: [BANNER, VIDEO, NATIVE], - - /** - * Determines whether or not the given bid request is valid. - * - * @param {object} bid The bid to validate. - * @return boolean True if this is a valid bid, and false otherwise. - */ + supportedMediaTypes: MEDIA_TYPES, + isBidRequestValid: (bid) => { - return !!(bid.params.placementId && bid.params.placementId.toString().length === 32); + const { params, mediaTypes } = bid; + + if (isStr(params.placementId) && params.placementId.length === 32 && mediaTypes) { + if ( + (mediaTypes[BANNER] && mediaTypes[BANNER].sizes) || + (mediaTypes[VIDEO] && mediaTypes[VIDEO].playerSize) || + (mediaTypes[NATIVE]) + ) { return true; } + } + + return false; }, - /** - * Make a server request from the list of BidRequests. - * - * @param {BidRequest[]} validBidRequests A non-empty list of valid bid requests that should be sent to the Server. - * @return ServerRequest Info describing the request to the server. - */ buildRequests: (validBidRequests, bidderRequest) => { - // convert Native ORTB definition to old-style prebid native definition - validBidRequests = convertOrtbRequestToProprietaryNative(validBidRequests); - if (validBidRequests.length === 0 || !bidderRequest) return []; - - const endpointURL = ENDPOINT_URL.replace('hash', validBidRequests[0].params.placementId); - - let imp = validBidRequests.map(br => { - let impObject = { - id: br.bidId, - secure: 1 - }; - - if (br.mediaTypes.banner) { - impObject.banner = createBannerRequest(br); - } else if (br.mediaTypes.video) { - impObject.video = createVideoRequest(br); - } else if (br.mediaTypes.native) { - impObject.native = { - // TODO: fix transactionId leak: https://github.com/prebid/Prebid.js/issues/9781 - // Also, `id` is not in the ORTB native spec - id: br.transactionId, - ver: '1.2', - request: createNativeRequest(br) - }; - } - return impObject; - }); + if (bidderRequest == undefined || validBidRequests[0] == undefined) { return []; } - let page = bidderRequest.refererInfo.page || bidderRequest.refererInfo.topmostLocation; + validBidRequests = convertOrtbRequestToProprietaryNative(validBidRequests); let data = { id: bidderRequest.bidderRequestId, - cur: [ DEFAULT_CUR ], - device: { - w: screen.width, - h: screen.height, - language: (navigator && navigator.language) ? navigator.language.indexOf('-') != -1 ? navigator.language.split('-')[0] : navigator.language : '', - ua: navigator.userAgent, - }, - site: { - domain: parseUrl(page).hostname, - page: page, - }, + imp: validBidRequests.map(adUnit => prepareImpression(adUnit)), + site: prepareSite(validBidRequests[0], bidderRequest), + device: bidderRequest.ortb2?.device || prepareDevice(), tmax: bidderRequest.timeout, - imp + cur: [ CUR ], + regs: { ext: {}, coppa: config.getConfig('coppa') == true ? 1 : 0 }, + source: { ext: { schain: validBidRequests[0].schain } }, + user: { ext: {} } }; - if (bidderRequest.refererInfo.ref) { - data.site.ref = bidderRequest.refererInfo.ref; - } - - if (bidderRequest.gdprConsent) { - data['regs'] = {'ext': {'gdpr': bidderRequest.gdprConsent.gdprApplies ? 1 : 0}}; - data['user'] = {'ext': {'consent': bidderRequest.gdprConsent.consentString ? bidderRequest.gdprConsent.consentString : ''}}; - } - - if (bidderRequest.uspConsent !== undefined) { - if (!data['regs'])data['regs'] = {'ext': {}}; - data['regs']['ext']['us_privacy'] = bidderRequest.uspConsent; - } - - if (config.getConfig('coppa') === true) { - if (!data['regs'])data['regs'] = {'coppa': 1}; - else data['regs']['coppa'] = 1; - } - - if (validBidRequests[0].schain) { - data['source'] = {'ext': {'schain': validBidRequests[0].schain}}; - } + prepareConsents(data, bidderRequest); + prepareEids(data, validBidRequests[0]); return { method: 'POST', - url: endpointURL, + url: ENDPOINT_URL + validBidRequests[0].params.placementId, data: data }; }, - /** - * Unpack the response from the server into a list of bids. - * - * @param {*} serverResponse A successful response from the server. - * @return {Bid[]} An array of bids which were nested inside the server. - */ interpretResponse: (serverResponse) => { - if (!serverResponse || isEmpty(serverResponse.body)) return []; - - let bids = []; - serverResponse.body.seatbid.forEach(response => { - response.bid.forEach(bid => { - let mediaType = bid.ext && bid.ext.mediaType ? bid.ext.mediaType : 'banner'; - - let bidObj = { - requestId: bid.impid, - cpm: bid.price, - width: bid.w, - height: bid.h, - ttl: 1200, - currency: DEFAULT_CUR, - netRevenue: true, - creativeId: bid.crid, - dealId: bid.dealid || null, - mediaType: mediaType - }; - - switch (mediaType) { - case 'video': - bidObj.vastUrl = bid.adm; - break; - case 'native': - bidObj.native = parseNative(bid.adm); - break; - default: - bidObj.ad = bid.adm; - } - - bids.push(bidObj); - }); + if (!serverResponse || isEmpty(serverResponse.body)) { + return []; + } + + const bidsArray = serverResponse.body.seatbid[0].bid.map((bidItem) => { + let bidObject = { + requestId: bidItem.impid, + cpm: bidItem.price, + width: bidItem.w, + height: bidItem.h, + ttl: TTL, + currency: CUR, + mediaType: bidItem?.ext?.mediaType ? bidItem.ext.mediaType : BANNER, + nurl: bidItem.nurl, + dealId: bidItem.dealid || null, + creativeId: bidItem.crid, + netRevenue: true + } + + if (bidObject.mediaType === VIDEO) { + bidObject.vastXml = bidItem.adm; + } else if (bidObject.mediaType === NATIVE) { + bidObject.native = prepareNativeAd(bidItem.adm); + } else { + bidObject.ad = bidItem.adm; + } + + return bidObject; }); - return bids; + return bidsArray; }, onBidWon: (bid) => { - if (isStr(bid.nurl) && bid.nurl !== '') { + if (isStr(bid.nurl)) { triggerPixel(bid.nurl); } } }; -const parseNative = adm => { - let bid = { - clickUrl: adm.native.link && adm.native.link.url, - impressionTrackers: adm.native.imptrackers || [], - clickTrackers: (adm.native.link && adm.native.link.clicktrackers) || [], - jstracker: adm.native.jstracker || [] - }; - adm.native.assets.forEach(asset => { - let kind = NATIVE_ASSETS_IDS[asset.id]; - let content = kind && asset[NATIVE_ASSETS[kind].name]; - if (content) { - bid[kind] = content.text || content.value || { url: content.url, width: content.w, height: content.h }; +registerBidder(spec); + +const getMediaTypeValues = { + [BANNER]: (adUnit) => { + let [w, h] = [300, 250]; + let format = []; + + if (isArrayOfNums(adUnit.mediaTypes.banner.sizes)) { + [w, h] = adUnit.mediaTypes.banner.sizes; + } else if (isArray(adUnit.mediaTypes.banner.sizes)) { + [w, h] = adUnit.mediaTypes.banner.sizes[0]; + if (adUnit.mediaTypes.banner.sizes.length > 1) { format = adUnit.mediaTypes.banner.sizes.map((size) => ({ w: size[0], h: size[1] })); } + } + + return { + w, + h, + format + } + }, + [NATIVE]: (adUnit) => { + let req = { + assets: [] + }; + + let assets = Object.keys(adUnit.mediaTypes.native); + + for (let asset of assets) { + let item = prepareAsset(asset, adUnit.mediaTypes.native[asset]); + if (item) { + item.required = adUnit.mediaTypes.native[asset].required ? 1 : 0; + req.assets.push(item); + } + } + + return { + ver: '1.2', + request: req + } + }, + [VIDEO]: (adUnit) => { + let videoObj = {...adUnit.mediaTypes.video}; + + if (videoObj.playerSize) { + const size = Array.isArray(videoObj.playerSize[0]) ? videoObj.playerSize[0] : videoObj.playerSize; + videoObj.w = size[0]; + videoObj.h = size[1]; + } else { + videoObj.w = 640; + videoObj.h = 480; } - }); - return bid; + return videoObj; + } } -const createNativeRequest = br => { - let impObject = { - ver: '1.2', - assets: [] - }; +function prepareAsset(assetKey, asset) { + let item = false; - let keys = Object.keys(br.mediaTypes.native); + let sizeTmp = false; + if (asset.sizes) { sizeTmp = Array.isArray(asset.sizes[0]) ? asset.sizes[0] : asset.sizes; } - for (let key of keys) { - const props = NATIVE_ASSETS[key]; - if (props) { - const asset = { - required: br.mediaTypes.native[key].required ? 1 : 0, - id: props.id, - [props.name]: {} + switch (assetKey) { + case 'title': + item = { + id: 1, + title: { len: asset.len || 25 } }; + break; + + case 'icon': + item = { + id: 2, + img: { type: 1 }, + w: sizeTmp[0] || 50, + h: sizeTmp[1] || 50 + }; + break; + + case 'image': + item = { + id: 3, + img: { type: 3 }, + w: sizeTmp[0] || 300, + h: sizeTmp[1] || 250 + }; + break; - if (props.type) asset[props.name]['type'] = props.type; - if (br.mediaTypes.native[key].len) asset[props.name]['len'] = br.mediaTypes.native[key].len; - if (br.mediaTypes.native[key].sizes && br.mediaTypes.native[key].sizes[0]) { - asset[props.name]['w'] = br.mediaTypes.native[key].sizes[0]; - asset[props.name]['h'] = br.mediaTypes.native[key].sizes[1]; - } + case 'sponsoredBy': + item = { + id: 4, + data: { type: 1, length: asset.len || 30 } + }; + break; - impObject.assets.push(asset); - } + case 'body': + item = { + id: 5, + data: { type: 2, length: asset.len || 100 } + }; + break; + + case 'rating': + item = { + id: 6, + data: { type: 3, length: asset.len || 25 } + }; + break; + + case 'downloads': + item = { + id: 7, + data: { type: 5, length: asset.len || 25 } + }; + break; + + case 'cta': + item = { + id: 8, + data: { type: 12, length: asset.len || 25 } + }; + break; } - return impObject; + return item; } -const createBannerRequest = br => { - let size = []; +function getFloor(adUnit, mediaType) { + let floor = DEF_FLOOR; - if (br.mediaTypes.banner.sizes && Array.isArray(br.mediaTypes.banner.sizes)) { - if (Array.isArray(br.mediaTypes.banner.sizes[0])) { size = br.mediaTypes.banner.sizes[0]; } else { size = br.mediaTypes.banner.sizes; } - } else size = [300, 250]; + if (!isFn(adUnit.getFloor)) { + return floor; + } - return { id: br.transactionId, w: size[0], h: size[1] }; -}; + let floorObj = adUnit.getFloor({ + currency: DEF_FLOOR, + mediaType, + size: '*' + }); -const createVideoRequest = br => { - let videoObj = {id: br.transactionId}; - let supportParamsList = ['mimes', 'minduration', 'maxduration', 'protocols', 'startdelay', 'skip', 'minbitrate', 'maxbitrate', 'api', 'linearity']; + if (isPlainObject(floorObj) && !isNaN(parseFloat(floorObj.floor))) { + floor = parseFloat(floorObj.floor) || floor; + } - for (let param of supportParamsList) { - if (br.mediaTypes.video[param] !== undefined) { - videoObj[param] = br.mediaTypes.video[param]; - } + return floor; +} + +function prepareImpression(adUnit) { + let mediaType = Object.keys(adUnit.mediaTypes)[0]; + + const impObj = { + id: adUnit.bidId, + secure: window.location.protocol.indexOf('https') !== -1 ? 1 : 0, + bidfloor: getFloor(adUnit, mediaType) + }; + + impObj[mediaType] = getMediaTypeValues[mediaType](adUnit); +} + +function prepareSite(adUnit, request) { + let siteObj = {}; + + siteObj.publisher = { + id: adUnit.params.placementId.toString() + }; + + siteObj.domain = parseUrl(request.refererInfo.page || request.refererInfo.topmostLocation).hostname; + siteObj.page = request.refererInfo.page || request.refererInfo.topmostLocation; + + if (request.refererInfo.ref) { + siteObj.site.ref = request.refererInfo.ref; } - if (br.mediaTypes.video.playerSize && Array.isArray(br.mediaTypes.video.playerSize)) { - if (Array.isArray(br.mediaTypes.video.playerSize[0])) { - videoObj.w = br.mediaTypes.video.playerSize[0][0]; - videoObj.h = br.mediaTypes.video.playerSize[0][1]; - } else { - videoObj.w = br.mediaTypes.video.playerSize[0]; - videoObj.h = br.mediaTypes.video.playerSize[1]; - } - } else { - videoObj.w = 640; - videoObj.h = 480; + return siteObj; +} + +function prepareConsents(data, request) { + if (request.gdprConsent !== undefined) { + data.regs.ext.gdpr = request.gdprConsent.gdprApplies ? 1 : 0; + data.user.ext.consent = request.gdprConsent.consentString ? request.gdprConsent.consentString : ''; + } + + if (request.uspConsent !== undefined) { + data.regs.ext.us_privacy = request.uspConsent; } - return videoObj; + return true; } -registerBidder(spec); +function prepareEids(data, adUnit) { + if (adUnit.userIdAsEids !== undefined) { + data.user.ext.eids = adUnit.userIdAsEids; + } + + return true; +} + +function prepareDevice() { + let deviceObj = {}; + + [deviceObj.w, deviceObj.h] = [screen.width, screen.height]; + deviceObj.language = navigator.language; + deviceObj.dnt = navigator.doNotTrack === '1' ? 1 : 0 + deviceObj.ua = navigator.userAgent; + + return deviceObj; +} + +function prepareNativeAd(adm) { + const nativeObj = JSON.parse(adm).native; + + let native = { + impressionTrackers: nativeObj.imptrackers || [], + jstracker: nativeObj.jstracker || [] + }; + + if (nativeObj.link) { + native.clickUrl = nativeObj.link.url || ''; + native.clickTrackers = nativeObj.link.clicktrackers || []; + } + + nativeObj.assets.forEach(asset => { + switch (asset.id) { + case 1: + native.title = asset.title ? asset.title.text : ''; + break; + + case 2: + native.icon = asset.img ? {url: asset.img.url, width: asset.img.w, height: asset.img.h} : {}; + break; + + case 3: + native.image = asset.img ? {url: asset.img.url, width: asset.img.w, height: asset.img.h} : {}; + break; + + case 4: + native.sponsoredBy = asset.data ? asset.data.value : ''; + break; + + case 5: + native.body = asset.data ? asset.data.value : ''; + break; + + case 6: + native.rating = asset.data ? asset.data.value : ''; + break; + + case 7: + native.downloads = asset.data ? asset.data.value : ''; + break; + + case 8: + native.cta = asset.data ? asset.data.value : ''; + break; + } + }); + + return native; +} diff --git a/test/spec/modules/videoheroesBidAdapter_spec.js b/test/spec/modules/videoheroesBidAdapter_spec.js index 8f99ca4d17d..5405a37b898 100644 --- a/test/spec/modules/videoheroesBidAdapter_spec.js +++ b/test/spec/modules/videoheroesBidAdapter_spec.js @@ -103,6 +103,7 @@ const response_banner = { price: 5, adomain: ['example.com'], adm: 'admcode', + nurl: 'https://trc.contextualadv.com/nurl/81d39516c777565f_2/0.1', crid: 'crid', ext: { mediaType: 'banner' @@ -121,6 +122,7 @@ const response_video = { price: 5, adomain: ['example.com'], adm: 'admcode', + nurl: 'https://trc.contextualadv.com/nurl/81d39516c777565f_2/0.1', crid: 'crid', ext: { mediaType: 'video' @@ -144,7 +146,7 @@ const response_native = { impid: 'request_imp_id', price: 5, adomain: ['example.com'], - adm: { native: + adm: JSON.stringify({ native: { assets: [ {id: 1, title: 'dummyText'}, @@ -158,7 +160,8 @@ const response_native = { imptrackers: ['tracker1.com', 'tracker2.com', 'tracker3.com'], jstracker: 'tracker1.com' } - }, + }), + nurl: 'https://trc.contextualadv.com/nurl/81d39516c777565f_2/0.1', crid: 'crid', ext: { mediaType: 'native' @@ -267,6 +270,7 @@ describe('VideoheroesBidAdapter', function() { netRevenue: true, creativeId: response_banner.seatbid[0].bid[0].crid, dealId: response_banner.seatbid[0].bid[0].dealid, + nurl: response_banner.seatbid[0].bid[0].nurl, mediaType: 'banner', ad: response_banner.seatbid[0].bid[0].adm } @@ -276,7 +280,7 @@ describe('VideoheroesBidAdapter', function() { expect(bannerResponses).to.be.an('array').that.is.not.empty; let dataItem = bannerResponses[0]; expect(dataItem).to.have.all.keys('requestId', 'cpm', 'width', 'height', 'ad', 'ttl', 'creativeId', - 'netRevenue', 'currency', 'dealId', 'mediaType'); + 'netRevenue', 'currency', 'dealId', 'nurl', 'mediaType'); expect(dataItem.requestId).to.equal(expectedBidResponse.requestId); expect(dataItem.cpm).to.equal(expectedBidResponse.cpm); expect(dataItem.ad).to.equal(expectedBidResponse.ad); @@ -284,6 +288,7 @@ describe('VideoheroesBidAdapter', function() { expect(dataItem.creativeId).to.equal(expectedBidResponse.creativeId); expect(dataItem.netRevenue).to.be.true; expect(dataItem.currency).to.equal(expectedBidResponse.currency); + expect(dataItem.nurl).to.equal(expectedBidResponse.nurl); expect(dataItem.width).to.equal(expectedBidResponse.width); expect(dataItem.height).to.equal(expectedBidResponse.height); }); @@ -303,23 +308,25 @@ describe('VideoheroesBidAdapter', function() { netRevenue: true, creativeId: response_video.seatbid[0].bid[0].crid, dealId: response_video.seatbid[0].bid[0].dealid, + nurl: response_banner.seatbid[0].bid[0].nurl, mediaType: 'video', - vastUrl: response_video.seatbid[0].bid[0].adm + vastXml: response_video.seatbid[0].bid[0].adm } let videoResponses = spec.interpretResponse(videoResponse); expect(videoResponses).to.be.an('array').that.is.not.empty; let dataItem = videoResponses[0]; - expect(dataItem).to.have.all.keys('requestId', 'cpm', 'width', 'height', 'vastUrl', 'ttl', 'creativeId', - 'netRevenue', 'currency', 'dealId', 'mediaType'); + expect(dataItem).to.have.all.keys('requestId', 'cpm', 'width', 'height', 'vastXml', 'ttl', 'creativeId', + 'netRevenue', 'currency', 'dealId', 'nurl', 'mediaType'); expect(dataItem.requestId).to.equal(expectedBidResponse.requestId); expect(dataItem.cpm).to.equal(expectedBidResponse.cpm); - expect(dataItem.vastUrl).to.equal(expectedBidResponse.vastUrl) + expect(dataItem.vastXml).to.equal(expectedBidResponse.vastXml) expect(dataItem.ttl).to.equal(expectedBidResponse.ttl); expect(dataItem.creativeId).to.equal(expectedBidResponse.creativeId); expect(dataItem.netRevenue).to.be.true; expect(dataItem.currency).to.equal(expectedBidResponse.currency); + expect(dataItem.nurl).to.equal(expectedBidResponse.nurl); expect(dataItem.width).to.equal(expectedBidResponse.width); expect(dataItem.height).to.equal(expectedBidResponse.height); }); @@ -339,8 +346,9 @@ describe('VideoheroesBidAdapter', function() { netRevenue: true, creativeId: response_native.seatbid[0].bid[0].crid, dealId: response_native.seatbid[0].bid[0].dealid, + nurl: response_banner.seatbid[0].bid[0].nurl, mediaType: 'native', - native: {clickUrl: response_native.seatbid[0].bid[0].adm.native.link.url} + native: {clickUrl: 'example.com'} } let nativeResponses = spec.interpretResponse(nativeResponse); @@ -348,7 +356,7 @@ describe('VideoheroesBidAdapter', function() { expect(nativeResponses).to.be.an('array').that.is.not.empty; let dataItem = nativeResponses[0]; expect(dataItem).to.have.all.keys('requestId', 'cpm', 'width', 'height', 'native', 'ttl', 'creativeId', - 'netRevenue', 'currency', 'dealId', 'mediaType'); + 'netRevenue', 'currency', 'dealId', 'nurl', 'mediaType'); expect(dataItem.requestId).to.equal(expectedBidResponse.requestId); expect(dataItem.cpm).to.equal(expectedBidResponse.cpm); expect(dataItem.native.clickUrl).to.equal(expectedBidResponse.native.clickUrl) @@ -356,6 +364,7 @@ describe('VideoheroesBidAdapter', function() { expect(dataItem.creativeId).to.equal(expectedBidResponse.creativeId); expect(dataItem.netRevenue).to.be.true; expect(dataItem.currency).to.equal(expectedBidResponse.currency); + expect(dataItem.nurl).to.equal(expectedBidResponse.nurl); expect(dataItem.width).to.equal(expectedBidResponse.width); expect(dataItem.height).to.equal(expectedBidResponse.height); });