• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

prebid / Prebid.js / 21826065111

09 Feb 2026 12:59PM UTC coverage: 96.232% (+0.007%) from 96.225%
21826065111

push

github

web-flow
gam video module: Include us_privacy based on gpp when downloading VAST for the IMA player (#14424)

* Include us_privacy based on gpp when downloading VAST for the IMA player

IMA player has no support for GPP, but does support uspapi.

When on `window`, there is only `__gpp`, we can still pass a US Privacy string by deriving the string from the `__gpp` data,

* Added tests for the retrieveUspInfoFromGpp method

* Re-write the test to use the public API instead of internal methods

* Deal with the option that the parsedSections contains sections which are arrays

* Deal with possible null/missing values in the GPP parsedSection

55045 of 67447 branches covered (81.61%)

75 of 76 new or added lines in 2 files covered. (98.68%)

12 existing lines in 1 file now uncovered.

210489 of 218730 relevant lines covered (96.23%)

71.81 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

95.6
/modules/gamAdServerVideo.js
1
/**
1✔
2
 * This module adds [GAM support]{@link https://www.doubleclickbygoogle.com/} for Video to Prebid.
3
 */
4

5
import { getSignals } from '../libraries/gptUtils/gptUtils.js';
6
import { registerVideoSupport } from '../src/adServerManager.js';
7
import { getPPID } from '../src/adserver.js';
8
import { auctionManager } from '../src/auctionManager.js';
9
import { config } from '../src/config.js';
10
import { EVENTS } from '../src/constants.js';
11
import * as events from '../src/events.js';
12
import { getHook } from '../src/hook.js';
13
import { getRefererInfo } from '../src/refererDetection.js';
14
import { targeting } from '../src/targeting.js';
15
import {
16
  buildUrl,
17
  formatQS,
18
  isEmpty,
19
  isNumber,
20
  logError,
21
  logWarn,
22
  parseSizesInput,
23
  parseUrl
24
} from '../src/utils.js';
25
import {DEFAULT_GAM_PARAMS, GAM_ENDPOINT, gdprParams} from '../libraries/gamUtils/gamUtils.js';
26
import { vastLocalCache } from '../src/videoCache.js';
27
import { fetch } from '../src/ajax.js';
28
import XMLUtil from '../libraries/xmlUtils/xmlUtils.js';
29

30
import {getGlobalVarName} from '../src/buildOptions.js';
31
import { gppDataHandler, uspDataHandler } from '../src/consentHandler.js';
32
/**
33
 * @typedef {Object} DfpVideoParams
34
 *
35
 * This object contains the params needed to form a URL which hits the
36
 * [DFP API]{@link https://support.google.com/dfp_premium/answer/1068325?hl=en}.
37
 *
38
 * All params (except iu, mentioned below) should be considered optional. This module will choose reasonable
39
 * defaults for all of the other required params.
40
 *
41
 * The cust_params property, if present, must be an object. It will be merged with the rest of the
42
 * standard Prebid targeting params (hb_adid, hb_bidder, etc).
43
 *
44
 * @param {string} iu This param *must* be included, in order for us to create a valid request.
45
 * @param [string] description_url This field is required if you want Ad Exchange to bid on our ad unit...
46
 *   but otherwise optional
47
 */
48

49
/**
50
 * @typedef {Object} DfpVideoOptions
51
 *
52
 * @param {Object} adUnit The adUnit which this bid is supposed to help fill.
53
 * @param [Object] bid The bid which should be considered alongside the rest of the adserver's demand.
54
 *   If this isn't defined, then we'll use the winning bid for the adUnit.
55
 *
56
 * @param {DfpVideoParams} [params] Query params which should be set on the DFP request.
57
 *   These will override this module's defaults whenever they conflict.
58
 * @param {string} [url] video adserver url
59
 */
60

61
export const dep = {
1✔
62
  ri: getRefererInfo
63
}
64

65
export const VAST_TAG_URI_TAGNAME = 'VASTAdTagURI';
1✔
66

67
/**
68
 * Merge all the bid data and publisher-supplied options into a single URL, and then return it.
69
 *
70
 * @see [The DFP API]{@link https://support.google.com/dfp_premium/answer/1068325?hl=en#env} for details.
71
 *
72
 * @param {DfpVideoOptions} options Options which should be used to construct the URL.
73
 *
74
 * @return {string} A URL which calls DFP, letting options.bid
75
 *   (or the auction's winning bid for this adUnit, if undefined) compete alongside the rest of the
76
 *   demand in DFP.
77
 */
78
export function buildGamVideoUrl(options) {
79
  if (!options.params && !options.url) {
91✔
80
    logError(`A params object or a url is required to use ${getGlobalVarName()}.adServers.gam.buildVideoUrl`);
5✔
81
    return;
5✔
82
  }
83

84
  const adUnit = options.adUnit;
86✔
85
  const bid = options.bid || targeting.getWinningBids(adUnit.code)[0];
86✔
86

87
  let urlComponents = {};
86✔
88

89
  if (options.url) {
86✔
90
    // when both `url` and `params` are given, parsed url will be overwriten
91
    // with any matching param components
92
    urlComponents = parseUrl(options.url, {noDecodeWholeURL: true});
19✔
93

94
    if (isEmpty(options.params)) {
19✔
95
      return buildUrlFromAdserverUrlComponents(urlComponents, bid, options);
10✔
96
    }
97
  }
98

99
  const derivedParams = {
76✔
100
    correlator: Date.now(),
101
    sz: parseSizesInput(adUnit?.mediaTypes?.video?.playerSize).join('|'),
532!
102
    url: encodeURIComponent(location.href),
103
  };
104

105
  const urlSearchComponent = urlComponents.search;
76✔
106
  const urlSzParam = urlSearchComponent && urlSearchComponent.sz;
76✔
107
  if (urlSzParam) {
76✔
108
    derivedParams.sz = urlSzParam + '|' + derivedParams.sz;
4✔
109
  }
110

111
  const encodedCustomParams = getCustParams(bid, options, urlSearchComponent && urlSearchComponent.cust_params);
76✔
112

113
  const queryParams = Object.assign({},
76✔
114
    DEFAULT_GAM_PARAMS,
115
    urlComponents.search,
116
    derivedParams,
117
    options.params,
118
    { cust_params: encodedCustomParams },
119
    gdprParams()
120
  );
121

122
  const descriptionUrl = getDescriptionUrl(bid, options, 'params');
76✔
123
  if (descriptionUrl) { queryParams.description_url = descriptionUrl; }
76✔
124

125
  if (!queryParams.ppid) {
76✔
126
    const ppid = getPPID();
76✔
127
    if (ppid != null) {
76✔
128
      queryParams.ppid = ppid;
2✔
129
    }
130
  }
131

132
  const video = options.adUnit?.mediaTypes?.video;
76!
133
  Object.entries({
76✔
134
    plcmt: () => video?.plcmt,
75!
135
    min_ad_duration: () => isNumber(video?.minduration) ? video.minduration * 1000 : null,
75!
136
    max_ad_duration: () => isNumber(video?.maxduration) ? video.maxduration * 1000 : null,
75!
137
    vpos() {
138
      const startdelay = video?.startdelay;
72!
139
      if (isNumber(startdelay)) {
72✔
140
        if (startdelay === -2) return 'postroll';
4✔
141
        if (startdelay === -1 || startdelay > 0) return 'midroll';
3✔
142
        return 'preroll';
1✔
143
      }
144
    },
145
    vconp: () => Array.isArray(video?.playbackmethod) && video.playbackmethod.some(m => m === 7) ? '2' : undefined,
74!
146
    vpa() {
147
      // playbackmethod = 3 is play on click; 1, 2, 4, 5, 6 are autoplay
148
      if (Array.isArray(video?.playbackmethod)) {
73!
149
        const click = video.playbackmethod.some(m => m === 3);
29✔
150
        const auto = video.playbackmethod.some(m => [1, 2, 4, 5, 6].includes(m));
16✔
151
        if (click && !auto) return 'click';
13✔
152
        if (auto && !click) return 'auto';
12✔
153
      }
154
    },
155
    vpmute() {
156
      // playbackmethod = 2, 6 are muted; 1, 3, 4, 5 are not
157
      if (Array.isArray(video?.playbackmethod)) {
73!
158
        const muted = video.playbackmethod.some(m => [2, 6].includes(m));
26✔
159
        const talkie = video.playbackmethod.some(m => [1, 3, 4, 5].includes(m));
17✔
160
        if (muted && !talkie) return '1';
13✔
161
        if (talkie && !muted) return '0';
12✔
162
      }
163
    }
164
  }).forEach(([param, getter]) => {
532✔
165
    if (!queryParams.hasOwnProperty(param)) {
532✔
166
      const val = getter();
517✔
167
      if (val != null) {
517✔
168
        queryParams[param] = val;
33✔
169
      }
170
    }
171
  });
172
  const fpd = auctionManager.index.getBidRequest(options.bid || {})?.ortb2 ??
76✔
173
    auctionManager.index.getAuction(options.bid || {})?.getFPD()?.global;
74✔
174

175
  const signals = getSignals(fpd);
76✔
176

177
  if (signals.length) {
76✔
178
    queryParams.ppsj = btoa(JSON.stringify({
4✔
179
      PublisherProvidedTaxonomySignals: signals
180
    }))
181
  }
182

183
  return buildUrl(Object.assign({}, GAM_ENDPOINT, urlComponents, { search: queryParams }));
76✔
184
}
185

186
export function notifyTranslationModule(fn) {
187
  fn.call(this, 'dfp');
×
188
}
189

190
if (config.getConfig('brandCategoryTranslation.translationFile')) { getHook('registerAdserver').before(notifyTranslationModule); }
1!
191

192
/**
193
 * Builds a video url from a base dfp video url and a winning bid, appending
194
 * Prebid-specific key-values.
195
 * @param {Object} components base video adserver url parsed into components object
196
 * @param {Object} bid winning bid object to append parameters from
197
 * @param {Object} options Options which should be used to construct the URL (used for custom params).
198
 * @return {string} video url
199
 */
200
function buildUrlFromAdserverUrlComponents(components, bid, options) {
201
  const descriptionUrl = getDescriptionUrl(bid, components, 'search');
10✔
202
  if (descriptionUrl) {
10✔
203
    components.search.description_url = descriptionUrl;
10✔
204
  }
205

206
  components.search.cust_params = getCustParams(bid, options, components.search.cust_params);
10✔
207
  return buildUrl(components);
10✔
208
}
209

210
/**
211
 * Returns the encoded vast url if it exists on a bid object, only if prebid-cache
212
 * is disabled, and description_url is not already set on a given input
213
 * @param {Object} bid object to check for vast url
214
 * @param {Object} components the object to check that description_url is NOT set on
215
 * @param {string} prop the property of components that would contain description_url
216
 * @return {string | undefined} The encoded vast url if it exists, or undefined
217
 */
218
function getDescriptionUrl(bid, components, prop) {
219
  return components?.[prop]?.description_url || encodeURIComponent(dep.ri().page);
86!
220
}
221

222
/**
223
 * Returns the encoded `cust_params` from the bid.adserverTargeting and adds the `hb_uuid`, and `hb_cache_id`. Optionally the options.params.cust_params
224
 * @param {Object} bid
225
 * @param {Object} options this is the options passed in from the `buildGamVideoUrl` function
226
 * @return {Object} Encoded key value pairs for cust_params
227
 */
228
function getCustParams(bid, options, urlCustParams) {
229
  const adserverTargeting = (bid && bid.adserverTargeting) || {};
86✔
230

231
  let allTargetingData = {};
86✔
232
  const adUnit = options && options.adUnit;
86✔
233
  if (adUnit) {
86✔
234
    const allTargeting = targeting.getAllTargeting(adUnit.code);
86✔
235
    allTargetingData = (allTargeting) ? allTargeting[adUnit.code] : {};
86!
236
  }
237

238
  const prebidTargetingSet = Object.assign({},
86✔
239
    // Why are we adding standard keys here ? Refer https://github.com/prebid/Prebid.js/issues/3664
240
    { hb_uuid: bid && bid.videoCacheKey },
171✔
241
    // hb_cache_id became optional in prebid 5.0 after 4.x enabled the concept of optional keys. Discussion led to reversing the prior expectation of deprecating hb_uuid
242
    { hb_cache_id: bid && bid.videoCacheKey },
171✔
243
    allTargetingData,
244
    adserverTargeting,
245
  );
246

247
  // TODO: WTF is this? just firing random events, guessing at the argument, hoping noone notices?
248
  events.emit(EVENTS.SET_TARGETING, {[adUnit.code]: prebidTargetingSet});
86✔
249

250
  // merge the prebid + publisher targeting sets
251
  const publisherTargetingSet = options?.params?.cust_params;
86✔
252
  const targetingSet = Object.assign({}, prebidTargetingSet, publisherTargetingSet);
86✔
253
  let encodedParams = encodeURIComponent(formatQS(targetingSet));
86✔
254
  if (urlCustParams) {
86✔
255
    encodedParams = urlCustParams + '%26' + encodedParams;
2✔
256
  }
257

258
  return encodedParams;
86✔
259
}
260

261
async function getVastForLocallyCachedBids(gamVastWrapper, localCacheMap) {
262
  try {
12✔
263
    const xmlUtil = XMLUtil();
12✔
264
    const xmlDoc = xmlUtil.parse(gamVastWrapper);
12✔
265
    const vastAdTagUriElement = xmlDoc.querySelectorAll(VAST_TAG_URI_TAGNAME)[0];
12✔
266

267
    if (!vastAdTagUriElement || !vastAdTagUriElement.textContent) {
12!
268
      return gamVastWrapper;
×
269
    }
270

271
    const uuidExp = new RegExp(`[A-Fa-f0-9]{8}-(?:[A-Fa-f0-9]{4}-){3}[A-Fa-f0-9]{12}`, 'gi');
12✔
272
    const matchResult = Array.from(vastAdTagUriElement.textContent.matchAll(uuidExp));
12✔
273
    const uuidCandidates = matchResult
12✔
274
      .map(([uuid]) => uuid)
12✔
275
      .filter(uuid => localCacheMap.has(uuid));
12✔
276

277
    if (uuidCandidates.length !== 1) {
4✔
278
      logWarn(`Unable to determine unique uuid in ${VAST_TAG_URI_TAGNAME}`);
3✔
279
      return gamVastWrapper;
3✔
280
    }
281
    const uuid = uuidCandidates[0];
1✔
282

283
    const blobUrl = localCacheMap.get(uuid);
1✔
284
    const base64BlobContent = await getBase64BlobContent(blobUrl);
1✔
285
    const cdata = xmlDoc.createCDATASection(base64BlobContent);
1✔
286
    vastAdTagUriElement.textContent = '';
1✔
287
    vastAdTagUriElement.appendChild(cdata);
1✔
288
    return xmlUtil.serialize(xmlDoc);
1✔
289
  } catch (error) {
290
    logWarn('Unable to process xml', error);
8✔
291
    return gamVastWrapper;
8✔
292
  }
293
};
294

295
export async function getVastXml(options, localCacheMap = vastLocalCache) {
13✔
296
  let vastUrl = buildGamVideoUrl(options);
13✔
297

298
  const adUnit = options.adUnit;
13✔
299
  const video = adUnit?.mediaTypes?.video;
13!
300
  const sdkApis = (video?.api || []).join(',');
13!
301
  const usPrivacy = uspDataHandler.getConsentData?.();
13!
302
  const gpp = gppDataHandler.getConsentData?.();
13!
303
  // Adding parameters required by ima
304
  if (config.getConfig('cache.useLocal') && window.google?.ima) {
13✔
305
    vastUrl = new URL(vastUrl);
8✔
306
    const imaSdkVersion = `h.${window.google.ima.VERSION}`;
8✔
307
    vastUrl.searchParams.set('omid_p', `Google1/${imaSdkVersion}`);
8✔
308
    vastUrl.searchParams.set('sdkv', imaSdkVersion);
8✔
309
    if (sdkApis) {
8!
310
      vastUrl.searchParams.set('sdk_apis', sdkApis);
×
311
    }
312
    if (usPrivacy) {
8✔
313
      vastUrl.searchParams.set('us_privacy', usPrivacy);
1✔
314
    } else if (gpp) {
7✔
315
      // Extract an usPrivacy string from the GPP string if possible
316
      const uspFromGpp = retrieveUspInfoFromGpp(gpp);
6✔
317
      if (uspFromGpp) {
6✔
318
        vastUrl.searchParams.set('us_privacy', uspFromGpp)
4✔
319
      }
320
    }
321

322
    vastUrl = vastUrl.toString();
8✔
323
  }
324

325
  const response = await fetch(vastUrl);
13✔
326
  if (!response.ok) {
13!
327
    throw new Error('Unable to fetch GAM VAST wrapper');
×
328
  }
329

330
  const gamVastWrapper = await response.text();
13✔
331

332
  if (config.getConfig('cache.useLocal')) {
13✔
333
    const vastXml = await getVastForLocallyCachedBids(gamVastWrapper, localCacheMap);
12✔
334
    return vastXml;
12✔
335
  }
336

337
  return gamVastWrapper;
1✔
338
}
339
/**
340
 * Extract a US Privacy string from the GPP data
341
 */
342
function retrieveUspInfoFromGpp(gpp) {
343
  if (!gpp) {
6!
NEW
344
    return undefined;
×
345
  }
346
  const parsedSections = gpp.gppData?.parsedSections;
6!
347
  if (parsedSections) {
6✔
348
    if (parsedSections.uspv1) {
6✔
349
      const usp = parsedSections.uspv1;
1✔
350
      return `${usp.Version}${usp.Notice}${usp.OptOutSale}${usp.LspaCovered}`
1✔
351
    } else {
352
      let saleOptOut;
353
      let saleOptOutNotice;
354
      Object.values(parsedSections).forEach(parsedSection => {
5✔
355
        (Array.isArray(parsedSection) ? parsedSection : [parsedSection]).forEach(ps => {
5✔
356
          const sectionSaleOptOut = ps.SaleOptOut;
6✔
357
          const sectionSaleOptOutNotice = ps.SaleOptOutNotice;
6✔
358
          if (saleOptOut === undefined && saleOptOutNotice === undefined && sectionSaleOptOut != null && sectionSaleOptOutNotice != null) {
6✔
359
            saleOptOut = sectionSaleOptOut;
3✔
360
            saleOptOutNotice = sectionSaleOptOutNotice;
3✔
361
          }
362
        });
363
      });
364
      if (saleOptOut !== undefined && saleOptOutNotice !== undefined) {
5✔
365
        const uspOptOutSale = saleOptOut === 0 ? '-' : saleOptOut === 1 ? 'Y' : 'N';
3!
366
        const uspOptOutNotice = saleOptOutNotice === 0 ? '-' : saleOptOutNotice === 1 ? 'Y' : 'N';
3!
367
        const uspLspa = uspOptOutSale === '-' && uspOptOutNotice === '-' ? '-' : 'Y';
3!
368
        return `1${uspOptOutNotice}${uspOptOutSale}${uspLspa}`;
3✔
369
      }
370
    }
371
  }
372
  return undefined
2✔
373
}
374

375
export async function getBase64BlobContent(blobUrl) {
376
  const response = await fetch(blobUrl);
1✔
377
  if (!response.ok) {
1!
378
    logError('Unable to fetch blob');
×
379
    throw new Error('Blob not found');
×
380
  }
381
  // Mechanism to handle cases where VAST tags are fetched
382
  // from a context where the blob resource is not accessible.
383
  // like IMA SDK iframe
384
  const blobContent = await response.text();
1✔
385
  const dataUrl = `data://text/xml;base64,${btoa(blobContent)}`;
1✔
386
  return dataUrl;
1✔
387
}
388

389
export { buildGamVideoUrl as buildDfpVideoUrl };
390

391
registerVideoSupport('gam', {
1✔
392
  buildVideoUrl: buildGamVideoUrl,
393
  getVastXml
394
});
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc