diff --git a/modules/dfpAdServerVideo.js b/modules/dfpAdServerVideo.js index a3e26dc7202..7f275992210 100644 --- a/modules/dfpAdServerVideo.js +++ b/modules/dfpAdServerVideo.js @@ -2,17 +2,28 @@ * This module adds [DFP support]{@link https://www.doubleclickbygoogle.com/} for Video to Prebid. */ -import { registerVideoSupport } from '../src/adServerManager.js'; -import { targeting } from '../src/targeting.js'; -import { deepAccess, isEmpty, logError, parseSizesInput, formatQS, parseUrl, buildUrl } from '../src/utils.js'; -import { config } from '../src/config.js'; -import { getHook, submodule } from '../src/hook.js'; -import { auctionManager } from '../src/auctionManager.js'; -import { gdprDataHandler, uspDataHandler, gppDataHandler } from '../src/adapterManager.js'; +import {registerVideoSupport} from '../src/adServerManager.js'; +import {targeting} from '../src/targeting.js'; +import { + isNumber, + buildUrl, + deepAccess, + formatQS, + isEmpty, + logError, + parseSizesInput, + parseUrl, + uniques +} from '../src/utils.js'; +import {config} from '../src/config.js'; +import {getHook, submodule} from '../src/hook.js'; +import {auctionManager} from '../src/auctionManager.js'; +import {gdprDataHandler} from '../src/adapterManager.js'; import * as events from '../src/events.js'; import CONSTANTS from '../src/constants.json'; import {getPPID} from '../src/adserver.js'; import {getRefererInfo} from '../src/refererDetection.js'; +import {CLIENT_SECTIONS} from '../src/fpd/oneClient.js'; /** * @typedef {Object} DfpVideoParams @@ -113,7 +124,6 @@ export function buildDfpVideoUrl(options) { const descriptionUrl = getDescriptionUrl(bid, options, 'params'); if (descriptionUrl) { queryParams.description_url = descriptionUrl; } - const gdprConsent = gdprDataHandler.getConsentData(); if (gdprConsent) { if (typeof gdprConsent.gdprApplies === 'boolean') { queryParams.gdpr = Number(gdprConsent.gdprApplies); } @@ -121,14 +131,6 @@ export function buildDfpVideoUrl(options) { if (gdprConsent.addtlConsent) { queryParams.addtl_consent = gdprConsent.addtlConsent; } } - const uspConsent = uspDataHandler.getConsentData(); - if (uspConsent) { queryParams.us_privacy = uspConsent; } - - const gppConsent = gppDataHandler.getConsentData(); - if (gppConsent) { - // TODO - need to know what to set here for queryParams... - } - if (!queryParams.ppid) { const ppid = getPPID(); if (ppid != null) { @@ -136,6 +138,70 @@ export function buildDfpVideoUrl(options) { } } + const video = options.adUnit?.mediaTypes?.video; + Object.entries({ + plcmt: () => video?.plcmt, + min_ad_duration: () => isNumber(video?.minduration) ? video.minduration * 1000 : null, + max_ad_duration: () => isNumber(video?.maxduration) ? video.maxduration * 1000 : null, + vpos() { + const startdelay = video?.startdelay; + if (isNumber(startdelay)) { + if (startdelay === -2) return 'postroll'; + if (startdelay === -1 || startdelay > 0) return 'midroll'; + return 'preroll'; + } + }, + vconp: () => Array.isArray(video?.playbackmethod) && video.playbackmethod.every(m => m === 7) ? '2' : undefined, + vpa() { + // playbackmethod = 3 is play on click; 1, 2, 4, 5, 6 are autoplay + if (Array.isArray(video?.playbackmethod)) { + const click = video.playbackmethod.some(m => m === 3); + const auto = video.playbackmethod.some(m => [1, 2, 4, 5, 6].includes(m)); + if (click && !auto) return 'click'; + if (auto && !click) return 'auto'; + } + }, + vpmute() { + // playbackmethod = 2, 6 are muted; 1, 3, 4, 5 are not + if (Array.isArray(video?.playbackmethod)) { + const muted = video.playbackmethod.some(m => [2, 6].includes(m)); + const talkie = video.playbackmethod.some(m => [1, 3, 4, 5].includes(m)); + if (muted && !talkie) return '1'; + if (talkie && !muted) return '0'; + } + } + }).forEach(([param, getter]) => { + if (!queryParams.hasOwnProperty(param)) { + const val = getter(); + if (val != null) { + queryParams[param] = val; + } + } + }); + const fpd = auctionManager.index.getBidRequest(options.bid || {})?.ortb2 ?? + auctionManager.index.getAuction(options.bid || {})?.getFPD()?.global; + + function getSegments(sections, segtax) { + return sections + .flatMap(section => deepAccess(fpd, section) || []) + .filter(datum => datum.ext?.segtax === segtax) + .flatMap(datum => datum.segment?.map(seg => seg.id)) + .filter(ob => ob) + .filter(uniques) + } + + const signals = Object.entries({ + IAB_AUDIENCE_1_1: getSegments(['user.data'], 4), + IAB_CONTENT_2_2: getSegments(CLIENT_SECTIONS.map(section => `${section}.content.data`), 6) + }).map(([taxonomy, values]) => values.length ? {taxonomy, values} : null) + .filter(ob => ob); + + if (signals.length) { + queryParams.ppsj = btoa(JSON.stringify({ + PublisherProvidedTaxonomySignals: signals + })) + } + return buildUrl(Object.assign({ protocol: 'https', host: 'securepubads.g.doubleclick.net', @@ -164,6 +230,8 @@ if (config.getConfig('brandCategoryTranslation.translationFile')) { getHook('reg * @returns {string} A URL which calls DFP with custom adpod targeting key values to compete with rest of the demand in DFP */ export function buildAdpodVideoUrl({code, params, callback} = {}) { + // TODO: the public API for this does not take in enough info to fill all DFP params (adUnit/bid), + // and is marked "alpha": https://docs.prebid.org/dev-docs/publisher-api-reference/adServers.dfp.buildAdpodVideoUrl.html if (!params || !callback) { logError(`A params object and a callback is required to use pbjs.adServers.dfp.buildAdpodVideoUrl`); return; @@ -225,9 +293,6 @@ export function buildAdpodVideoUrl({code, params, callback} = {}) { if (gdprConsent.addtlConsent) { queryParams.addtl_consent = gdprConsent.addtlConsent; } } - const uspConsent = uspDataHandler.getConsentData(); - if (uspConsent) { queryParams.us_privacy = uspConsent; } - const masterTag = buildUrl({ protocol: 'https', host: 'securepubads.g.doubleclick.net', diff --git a/test/spec/modules/dfpAdServerVideo_spec.js b/test/spec/modules/dfpAdServerVideo_spec.js index 4c12e9fa211..39713c2b51a 100644 --- a/test/spec/modules/dfpAdServerVideo_spec.js +++ b/test/spec/modules/dfpAdServerVideo_spec.js @@ -1,43 +1,61 @@ -import { expect } from 'chai'; +import {expect} from 'chai'; import parse from 'url-parse'; -import {buildDfpVideoUrl, buildAdpodVideoUrl, dep} from 'modules/dfpAdServerVideo.js'; -import adUnit from 'test/fixtures/video/adUnit.json'; +import {buildAdpodVideoUrl, buildDfpVideoUrl, dep} from 'modules/dfpAdServerVideo.js'; +import AD_UNIT from 'test/fixtures/video/adUnit.json'; import * as utils from 'src/utils.js'; -import { config } from 'src/config.js'; -import { targeting } from 'src/targeting.js'; -import { auctionManager } from 'src/auctionManager.js'; -import { gdprDataHandler, uspDataHandler } from 'src/adapterManager.js'; +import {deepClone} from 'src/utils.js'; +import {config} from 'src/config.js'; +import {targeting} from 'src/targeting.js'; +import {auctionManager} from 'src/auctionManager.js'; +import {gdprDataHandler, uspDataHandler} from 'src/adapterManager.js'; import * as adpod from 'modules/adpod.js'; -import { server } from 'test/mocks/xhr.js'; +import {server} from 'test/mocks/xhr.js'; import * as adServer from 'src/adserver.js'; -import {deepClone} from 'src/utils.js'; import {hook} from '../../../src/hook.js'; -import {getRefererInfo} from '../../../src/refererDetection.js'; - -const bid = { - videoCacheKey: 'abc', - adserverTargeting: { - hb_uuid: 'abc', - hb_cache_id: 'abc', - }, -}; +import {stubAuctionIndex} from '../../helpers/indexStub.js'; +import {AuctionIndex} from '../../../src/auctionIndex.js'; describe('The DFP video support module', function () { before(() => { hook.ready(); }); - let sandbox; + let sandbox, bid, adUnit; beforeEach(() => { sandbox = sinon.sandbox.create(); + bid = { + videoCacheKey: 'abc', + adserverTargeting: { + hb_uuid: 'abc', + hb_cache_id: 'abc', + }, + }; + adUnit = deepClone(AD_UNIT); }); afterEach(() => { sandbox.restore(); }); + function getURL(options) { + return parse(buildDfpVideoUrl(Object.assign({ + adUnit: adUnit, + bid: bid, + params: { + 'iu': 'my/adUnit' + } + }, options))) + } + function getQueryParams(options) { + return utils.parseQS(getURL(options).query); + } + + function getCustomParams(options) { + return utils.parseQS('?' + decodeURIComponent(getQueryParams(options).cust_params)); + } + Object.entries({ params: { params: { @@ -51,37 +69,25 @@ describe('The DFP video support module', function () { describe(`when using ${t}`, () => { it('should use page location as default for description_url', () => { sandbox.stub(dep, 'ri').callsFake(() => ({page: 'example.com'})); - - const url = parse(buildDfpVideoUrl(Object.assign({ - adUnit: adUnit, - bid: bid, - }, options))); - const prm = utils.parseQS(url.query); + const prm = getQueryParams(options); expect(prm.description_url).to.eql('example.com'); }); it('should use a URI encoded page location as default for description_url', () => { sandbox.stub(dep, 'ri').callsFake(() => ({page: 'https://example.com?iu=/99999999/news&cust_params=current_hour%3D12%26newscat%3Dtravel&pbjs_debug=true'})); - const url = parse(buildDfpVideoUrl(Object.assign({ - adUnit: adUnit, - bid: bid, - }, options))); - const prm = utils.parseQS(url.query); + const prm = getQueryParams(options); expect(prm.description_url).to.eql('https%3A%2F%2Fexample.com%3Fiu%3D%2F99999999%2Fnews%26cust_params%3Dcurrent_hour%253D12%2526newscat%253Dtravel%26pbjs_debug%3Dtrue'); }); }); }) it('should make a legal request URL when given the required params', function () { - const url = parse(buildDfpVideoUrl({ - adUnit: adUnit, - bid: bid, + const url = getURL({ params: { 'iu': 'my/adUnit', 'description_url': 'someUrl.com', } - })); - + }) expect(url.protocol).to.equal('https:'); expect(url.host).to.equal('securepubads.g.doubleclick.net'); @@ -98,15 +104,10 @@ describe('The DFP video support module', function () { }); it('can take an adserver url as a parameter', function () { - const bidCopy = utils.deepClone(bid); - bidCopy.vastUrl = 'vastUrl.example'; - - const url = parse(buildDfpVideoUrl({ - adUnit: adUnit, - bid: bidCopy, + bid.vastUrl = 'vastUrl.example'; + const url = getURL({ url: 'https://video.adserver.example/', - })); - + }) expect(url.host).to.equal('video.adserver.example'); }); @@ -120,161 +121,64 @@ describe('The DFP video support module', function () { }); it('overwrites url params when both url and params object are given', function () { - const url = parse(buildDfpVideoUrl({ - adUnit: adUnit, - bid: bid, + const params = getQueryParams({ url: 'https://video.adserver.example/ads?sz=640x480&iu=/123/aduniturl&impl=s', params: { iu: 'my/adUnit' } - })); + }); - const queryObject = utils.parseQS(url.query); - expect(queryObject.iu).to.equal('my/adUnit'); + expect(params.iu).to.equal('my/adUnit'); }); it('should override param defaults with user-provided ones', function () { - const url = parse(buildDfpVideoUrl({ - adUnit: adUnit, - bid: bid, + const params = getQueryParams({ params: { - 'iu': 'my/adUnit', 'output': 'vast', } - })); - - expect(utils.parseQS(url.query)).to.have.property('output', 'vast'); + }); + expect(params.output).to.equal('vast'); }); it('should include the cache key and adserver targeting in cust_params', function () { - const bidCopy = utils.deepClone(bid); - bidCopy.adserverTargeting = Object.assign(bidCopy.adserverTargeting, { + bid.adserverTargeting = Object.assign(bid.adserverTargeting, { hb_adid: 'ad_id', }); - const url = parse(buildDfpVideoUrl({ - adUnit: adUnit, - bid: bidCopy, - params: { - 'iu': 'my/adUnit' - } - })); - const queryObject = utils.parseQS(url.query); - const customParams = utils.parseQS('?' + decodeURIComponent(queryObject.cust_params)); + const customParams = getCustomParams() expect(customParams).to.have.property('hb_adid', 'ad_id'); expect(customParams).to.have.property('hb_uuid', bid.videoCacheKey); expect(customParams).to.have.property('hb_cache_id', bid.videoCacheKey); }); - it('should include the us_privacy key when USP Consent is available', function () { - let uspDataHandlerStub = sinon.stub(uspDataHandler, 'getConsentData'); - uspDataHandlerStub.returns('1YYY'); - - const bidCopy = utils.deepClone(bid); - bidCopy.adserverTargeting = Object.assign(bidCopy.adserverTargeting, { - hb_adid: 'ad_id', - }); - - const url = parse(buildDfpVideoUrl({ - adUnit: adUnit, - bid: bidCopy, - params: { - 'iu': 'my/adUnit' - } - })); - const queryObject = utils.parseQS(url.query); - expect(queryObject.us_privacy).to.equal('1YYY'); - uspDataHandlerStub.restore(); - }); - - it('should not include the us_privacy key when USP Consent is not available', function () { - const bidCopy = utils.deepClone(bid); - bidCopy.adserverTargeting = Object.assign(bidCopy.adserverTargeting, { - hb_adid: 'ad_id', - }); - - const url = parse(buildDfpVideoUrl({ - adUnit: adUnit, - bid: bidCopy, - params: { - 'iu': 'my/adUnit' - } - })); - const queryObject = utils.parseQS(url.query); - expect(queryObject.us_privacy).to.equal(undefined); - }); - it('should include the GDPR keys when GDPR Consent is available', function () { - let gdprDataHandlerStub = sinon.stub(gdprDataHandler, 'getConsentData'); - gdprDataHandlerStub.returns({ + sandbox.stub(gdprDataHandler, 'getConsentData').returns({ gdprApplies: true, consentString: 'consent', addtlConsent: 'moreConsent' }); - - const bidCopy = utils.deepClone(bid); - bidCopy.adserverTargeting = Object.assign(bidCopy.adserverTargeting, { - hb_adid: 'ad_id', - }); - - const url = parse(buildDfpVideoUrl({ - adUnit: adUnit, - bid: bidCopy, - params: { - 'iu': 'my/adUnit' - } - })); - const queryObject = utils.parseQS(url.query); + const queryObject = getQueryParams(); expect(queryObject.gdpr).to.equal('1'); expect(queryObject.gdpr_consent).to.equal('consent'); expect(queryObject.addtl_consent).to.equal('moreConsent'); - gdprDataHandlerStub.restore(); }); it('should not include the GDPR keys when GDPR Consent is not available', function () { - const bidCopy = utils.deepClone(bid); - bidCopy.adserverTargeting = Object.assign(bidCopy.adserverTargeting, { - hb_adid: 'ad_id', - }); - - const url = parse(buildDfpVideoUrl({ - adUnit: adUnit, - bid: bidCopy, - params: { - 'iu': 'my/adUnit' - } - })); - const queryObject = utils.parseQS(url.query); + const queryObject = getQueryParams() expect(queryObject.gdpr).to.equal(undefined); expect(queryObject.gdpr_consent).to.equal(undefined); expect(queryObject.addtl_consent).to.equal(undefined); }); it('should only include the GDPR keys for GDPR Consent fields with values', function () { - let gdprDataHandlerStub = sinon.stub(gdprDataHandler, 'getConsentData'); - gdprDataHandlerStub.returns({ + sandbox.stub(gdprDataHandler, 'getConsentData').returns({ gdprApplies: true, consentString: 'consent', }); - - const bidCopy = utils.deepClone(bid); - bidCopy.adserverTargeting = Object.assign(bidCopy.adserverTargeting, { - hb_adid: 'ad_id', - }); - - const url = parse(buildDfpVideoUrl({ - adUnit: adUnit, - bid: bidCopy, - params: { - 'iu': 'my/adUnit' - } - })); - const queryObject = utils.parseQS(url.query); + const queryObject = getQueryParams() expect(queryObject.gdpr).to.equal('1'); expect(queryObject.gdpr_consent).to.equal('consent'); expect(queryObject.addtl_consent).to.equal(undefined); - gdprDataHandlerStub.restore(); }); - describe('GAM PPID', () => { let ppid; let getPPIDStub; @@ -290,29 +194,283 @@ describe('The DFP video support module', function () { 'url': {url: 'https://video.adserver.mock/', params: {'iu': 'mock/unit'}} }).forEach(([t, opts]) => { describe(`when using ${t}`, () => { - function buildUrlAndGetParams() { - const url = parse(buildDfpVideoUrl(Object.assign({ - adUnit: adUnit, - bid: deepClone(bid), - }, opts))); - return utils.parseQS(url.query); - } - it('should be included if available', () => { ppid = 'mockPPID'; - const q = buildUrlAndGetParams(); + const q = getQueryParams(opts); expect(q.ppid).to.equal('mockPPID'); }); it('should not be included if not available', () => { ppid = undefined; - const q = buildUrlAndGetParams(); + const q = getQueryParams(opts); expect(q.hasOwnProperty('ppid')).to.be.false; }) }) }) }) + describe('ORTB video parameters', () => { + Object.entries({ + plcmt: [ + { + video: { + plcmt: 1 + }, + expected: '1' + } + ], + min_ad_duration: [ + { + video: { + minduration: 123 + }, + expected: '123000' + } + ], + max_ad_duration: [ + { + video: { + maxduration: 321 + }, + expected: '321000' + } + ], + vpos: [ + { + video: { + startdelay: 0 + }, + expected: 'preroll' + }, + { + video: { + startdelay: -1 + }, + expected: 'midroll' + }, + { + video: { + startdelay: -2 + }, + expected: 'postroll' + }, + { + video: { + startdelay: 10 + }, + expected: 'midroll' + } + ], + vconp: [ + { + video: { + playbackmethod: [7] + }, + expected: '2' + }, + { + video: { + playbackmethod: [7, 1] + }, + expected: undefined + } + ], + vpa: [ + { + video: { + playbackmethod: [1, 2, 4, 5, 6, 7] + }, + expected: 'auto' + }, + { + video: { + playbackmethod: [3, 7], + }, + expected: 'click' + }, + { + video: { + playbackmethod: [1, 3], + }, + expected: undefined + } + ], + vpmute: [ + { + video: { + playbackmethod: [1, 3, 4, 5, 7] + }, + expected: '0' + }, + { + video: { + playbackmethod: [2, 6, 7], + }, + expected: '1' + }, + { + video: { + playbackmethod: [1, 2] + }, + expected: undefined + } + ] + }).forEach(([param, cases]) => { + describe(param, () => { + cases.forEach(({video, expected}) => { + describe(`when mediaTypes.video has ${JSON.stringify(video)}`, () => { + it(`fills in ${param} = ${expected}`, () => { + Object.assign(adUnit.mediaTypes.video, video); + expect(getQueryParams()[param]).to.eql(expected); + }); + it(`does not override pub-provided params.${param}`, () => { + Object.assign(adUnit.mediaTypes.video, video); + expect(getQueryParams({ + params: { + [param]: 'OG' + } + })[param]).to.eql('OG'); + }); + it('does not fill if param has no value', () => { + expect(getQueryParams().hasOwnProperty(param)).to.be.false; + }) + }) + }) + }) + }) + }); + + describe('ppsj', () => { + let ortb2; + beforeEach(() => { + ortb2 = null; + }) + + function getSignals() { + const ppsj = JSON.parse(atob(getQueryParams().ppsj)); + return Object.fromEntries(ppsj.PublisherProvidedTaxonomySignals.map(sig => [sig.taxonomy, sig.values])); + } + + Object.entries({ + 'FPD from bid request'() { + bid.requestId = 'req-id'; + sandbox.stub(auctionManager, 'index').get(() => stubAuctionIndex({ + bidRequests: [ + { + bidId: 'req-id', + ortb2 + } + ] + })); + }, + 'global FPD from auction'() { + bid.auctionId = 'auid'; + sandbox.stub(auctionManager, 'index').get(() => new AuctionIndex(() => [{ + getAuctionId: () => 'auid', + getFPD: () => ({ + global: ortb2 + }) + }])); + } + }).forEach(([t, setup]) => { + describe(`using ${t}`, () => { + beforeEach(setup); + it('does not fill if there\'s no segments in segtax 4 or 6', () => { + ortb2 = { + site: { + content: { + data: [ + { + segment: [ + {id: '1'}, + {id: '2'} + ] + }, + ] + } + }, + user: { + data: [ + { + ext: { + segtax: 1, + }, + segment: [ + {id: '3'} + ] + } + ] + } + } + expect(getQueryParams().ppsj).to.not.exist; + }); + + const SEGMENTS = [ + { + ext: { + segtax: 4, + }, + segment: [ + {id: '4-1'}, + {id: '4-2'} + ] + }, + { + ext: { + segtax: 4, + }, + segment: [ + {id: '4-2'}, + {id: '4-3'} + ] + }, + { + ext: { + segtax: 6, + }, + segment: [ + {id: '6-1'}, + {id: '6-2'} + ] + }, + { + ext: { + segtax: 6, + }, + segment: [ + {id: '6-2'}, + {id: '6-3'} + ] + }, + ] + + it('collects user.data segments with segtax = 4 into IAB_AUDIENCE_1_1', () => { + ortb2 = { + user: { + data: SEGMENTS + } + } + expect(getSignals()).to.eql({ + IAB_AUDIENCE_1_1: ['4-1', '4-2', '4-3'] + }) + }) + + it('collects site.content.data segments with segtax = 6 into IAB_CONTENT_2_2', () => { + ortb2 = { + site: { + content: { + data: SEGMENTS + } + } + } + expect(getSignals()).to.eql({ + IAB_CONTENT_2_2: ['6-1', '6-2', '6-3'] + }) + }) + }) + }) + }) + describe('special targeting unit test', function () { const allTargetingData = { 'hb_format': 'video', @@ -639,7 +797,6 @@ describe('The DFP video support module', function () { expect(queryParams).to.have.property('unviewed_position_start', '1'); expect(queryParams).to.have.property('url'); expect(queryParams).to.have.property('cust_params'); - expect(queryParams).to.have.property('us_privacy', '1YYY'); expect(queryParams).to.have.property('gdpr', '1'); expect(queryParams).to.have.property('gdpr_consent', 'consent'); expect(queryParams).to.have.property('addtl_consent', 'moreConsent');