mk9-prebid
Version:
Header Bidding Management Library
655 lines (529 loc) • 16.4 kB
JavaScript
import { registerBidder } from '../src/adapters/bidderFactory.js';
import { config } from '../src/config.js';
import * as utils from '../src/utils.js';
import {BANNER, VIDEO} from '../src/mediaTypes.js';
const BIDDER_CODE = '33across';
const END_POINT = 'https://ssc.33across.com/api/v1/hb';
const SYNC_ENDPOINT = 'https://ssc-cms.33across.com/ps/?m=xch&rt=html&ru=deb';
const CURRENCY = 'USD';
const GUID_PATTERN = /^[a-zA-Z0-9_-]{22}$/;
const PRODUCT = {
SIAB: 'siab',
INVIEW: 'inview',
INSTREAM: 'instream'
};
const VIDEO_ORTB_PARAMS = [
'mimes',
'minduration',
'maxduration',
'placement',
'protocols',
'startdelay',
'skip',
'skipafter',
'minbitrate',
'maxbitrate',
'delivery',
'playbackmethod',
'api',
'linearity'
];
const adapterState = {
uniqueSiteIds: []
};
const NON_MEASURABLE = 'nm';
// **************************** VALIDATION *************************** //
function isBidRequestValid(bid) {
return (
_validateBasic(bid) &&
_validateBanner(bid) &&
_validateVideo(bid)
);
}
function _validateBasic(bid) {
if (bid.bidder !== BIDDER_CODE || typeof bid.params === 'undefined') {
return false;
}
if (!_validateGUID(bid)) {
return false;
}
return true;
}
function _validateGUID(bid) {
const siteID = utils.deepAccess(bid, 'params.siteId', '') || '';
if (siteID.trim().match(GUID_PATTERN) === null) {
return false;
}
return true;
}
function _validateBanner(bid) {
const banner = utils.deepAccess(bid, 'mediaTypes.banner');
// If there's no banner no need to validate against banner rules
if (banner === undefined) {
return true;
}
if (!Array.isArray(banner.sizes)) {
return false;
}
return true;
}
function _validateVideo(bid) {
const videoAdUnit = utils.deepAccess(bid, 'mediaTypes.video');
const videoBidderParams = utils.deepAccess(bid, 'params.video', {});
// If there's no video no need to validate against video rules
if (videoAdUnit === undefined) {
return true;
}
if (!Array.isArray(videoAdUnit.playerSize)) {
return false;
}
if (!videoAdUnit.context) {
return false;
}
const videoParams = {
...videoAdUnit,
...videoBidderParams
};
if (!Array.isArray(videoParams.mimes) || videoParams.mimes.length === 0) {
return false;
}
if (!Array.isArray(videoParams.protocols) || videoParams.protocols.length === 0) {
return false;
}
// If placement if defined, it must be a number
if (
typeof videoParams.placement !== 'undefined' &&
typeof videoParams.placement !== 'number'
) {
return false;
}
// If startdelay is defined it must be a number
if (
videoAdUnit.context === 'instream' &&
typeof videoParams.startdelay !== 'undefined' &&
typeof videoParams.startdelay !== 'number'
) {
return false;
}
return true;
}
// **************************** BUILD REQUESTS *************************** //
// NOTE: With regards to gdrp consent data, the server will independently
// infer the gdpr applicability therefore, setting the default value to false
function buildRequests(bidRequests, bidderRequest) {
const gdprConsent = Object.assign({
consentString: undefined,
gdprApplies: false
}, bidderRequest && bidderRequest.gdprConsent);
const uspConsent = bidderRequest && bidderRequest.uspConsent;
const pageUrl = (bidderRequest && bidderRequest.refererInfo) ? (bidderRequest.refererInfo.referer) : (undefined);
adapterState.uniqueSiteIds = bidRequests.map(req => req.params.siteId).filter(utils.uniques);
return bidRequests.map(bidRequest => _createServerRequest(
{
bidRequest,
gdprConsent,
uspConsent,
pageUrl
})
);
}
// Infer the necessary data from valid bid for a minimal ttxRequest and create HTTP request
// NOTE: At this point, TTX only accepts request for a single impression
function _createServerRequest({bidRequest, gdprConsent = {}, uspConsent, pageUrl}) {
const ttxRequest = {};
const params = bidRequest.params;
/*
* Infer data for the request payload
*/
ttxRequest.imp = [{}];
if (utils.deepAccess(bidRequest, 'mediaTypes.banner')) {
ttxRequest.imp[0].banner = {
..._buildBannerORTB(bidRequest)
}
}
if (utils.deepAccess(bidRequest, 'mediaTypes.video')) {
ttxRequest.imp[0].video = _buildVideoORTB(bidRequest);
}
ttxRequest.imp[0].ext = {
ttx: {
prod: _getProduct(bidRequest)
}
};
ttxRequest.site = { id: params.siteId };
if (pageUrl) {
ttxRequest.site.page = pageUrl;
}
// Go ahead send the bidId in request to 33exchange so it's kept track of in the bid response and
// therefore in ad targetting process
ttxRequest.id = bidRequest.bidId;
if (gdprConsent.consentString) {
ttxRequest.user = setExtension(
ttxRequest.user,
'consent',
gdprConsent.consentString
)
}
if (Array.isArray(bidRequest.userIdAsEids) && bidRequest.userIdAsEids.length > 0) {
ttxRequest.user = setExtension(
ttxRequest.user,
'eids',
bidRequest.userIdAsEids
)
}
ttxRequest.regs = setExtension(
ttxRequest.regs,
'gdpr',
Number(gdprConsent.gdprApplies)
);
if (uspConsent) {
ttxRequest.regs = setExtension(
ttxRequest.regs,
'us_privacy',
uspConsent
)
}
ttxRequest.ext = {
ttx: {
prebidStartedAt: Date.now(),
caller: [ {
'name': 'prebidjs',
'version': '$prebid.version$'
} ]
}
};
if (bidRequest.schain) {
ttxRequest.source = setExtension(
ttxRequest.source,
'schain',
bidRequest.schain
)
}
// Finally, set the openRTB 'test' param if this is to be a test bid
if (params.test === 1) {
ttxRequest.test = 1;
}
/*
* Now construct the full server request
*/
const options = {
contentType: 'text/plain',
withCredentials: true
};
// Allow the ability to configure the HB endpoint for testing purposes.
const ttxSettings = config.getConfig('ttxSettings');
const url = (ttxSettings && ttxSettings.url) || `${END_POINT}?guid=${params.siteId}`;
// Return the server request
return {
'method': 'POST',
'url': url,
'data': JSON.stringify(ttxRequest),
'options': options
}
}
// BUILD REQUESTS: SET EXTENSIONS
function setExtension(obj = {}, key, value) {
return Object.assign({}, obj, {
ext: Object.assign({}, obj.ext, {
[key]: value
})
});
}
// BUILD REQUESTS: SIZE INFERENCE
function _transformSizes(sizes) {
if (utils.isArray(sizes) && sizes.length === 2 && !utils.isArray(sizes[0])) {
return [ _getSize(sizes) ];
}
return sizes.map(_getSize);
}
function _getSize(size) {
return {
w: parseInt(size[0], 10),
h: parseInt(size[1], 10)
}
}
// BUILD REQUESTS: PRODUCT INFERENCE
function _getProduct(bidRequest) {
const { params, mediaTypes } = bidRequest;
const { banner, video } = mediaTypes;
if ((video && !banner) && video.context === 'instream') {
return PRODUCT.INSTREAM;
}
return (params.productId === PRODUCT.INVIEW) ? (params.productId) : PRODUCT.SIAB;
}
// BUILD REQUESTS: BANNER
function _buildBannerORTB(bidRequest) {
const bannerAdUnit = utils.deepAccess(bidRequest, 'mediaTypes.banner', {});
const element = _getAdSlotHTMLElement(bidRequest.adUnitCode);
const sizes = _transformSizes(bannerAdUnit.sizes);
let format;
// We support size based bidfloors so obtain one if there's a rule associated
if (typeof bidRequest.getFloor === 'function') {
format = sizes.map((size) => {
const bidfloors = _getBidFloors(bidRequest, size, BANNER);
let formatExt;
if (bidfloors) {
formatExt = {
ext: {
ttx: {
bidfloors: [ bidfloors ]
}
}
}
}
return Object.assign({}, size, formatExt);
});
} else {
format = sizes;
}
const minSize = _getMinSize(sizes);
const viewabilityAmount = _isViewabilityMeasurable(element)
? _getViewability(element, utils.getWindowTop(), minSize)
: NON_MEASURABLE;
const ext = contributeViewability(viewabilityAmount);
return {
format,
ext
}
}
// BUILD REQUESTS: VIDEO
// eslint-disable-next-line no-unused-vars
function _buildVideoORTB(bidRequest) {
const videoAdUnit = utils.deepAccess(bidRequest, 'mediaTypes.video', {});
const videoBidderParams = utils.deepAccess(bidRequest, 'params.video', {});
const videoParams = {
...videoAdUnit,
...videoBidderParams // Bidder Specific overrides
};
const video = {}
const {w, h} = _getSize(videoParams.playerSize[0]);
video.w = w;
video.h = h;
// Obtain all ORTB params related video from Ad Unit
VIDEO_ORTB_PARAMS.forEach((param) => {
if (videoParams.hasOwnProperty(param)) {
video[param] = videoParams[param];
}
});
const product = _getProduct(bidRequest);
// Placement Inference Rules:
// - If no placement is defined then default to 2 (In Banner)
// - If product is instream (for instream context) then override placement to 1
video.placement = video.placement || 2;
if (product === PRODUCT.INSTREAM) {
video.startdelay = video.startdelay || 0;
video.placement = 1;
};
// bidfloors
if (typeof bidRequest.getFloor === 'function') {
const bidfloors = _getBidFloors(bidRequest, {w: video.w, h: video.h}, VIDEO);
if (bidfloors) {
Object.assign(video, {
ext: {
ttx: {
bidfloors: [ bidfloors ]
}
}
});
}
}
return video;
}
// BUILD REQUESTS: BIDFLOORS
function _getBidFloors(bidRequest, size, mediaType) {
const bidFloors = bidRequest.getFloor({
currency: CURRENCY,
mediaType,
size: [ size.w, size.h ]
});
if (!isNaN(bidFloors.floor) && (bidFloors.currency === CURRENCY)) {
return bidFloors.floor;
}
}
// BUILD REQUESTS: VIEWABILITY
function _isViewabilityMeasurable(element) {
return !_isIframe() && element !== null;
}
function _getViewability(element, topWin, { w, h } = {}) {
return topWin.document.visibilityState === 'visible'
? _getPercentInView(element, topWin, { w, h })
: 0;
}
function _mapAdUnitPathToElementId(adUnitCode) {
if (utils.isGptPubadsDefined()) {
// eslint-disable-next-line no-undef
const adSlots = googletag.pubads().getSlots();
const isMatchingAdSlot = utils.isSlotMatchingAdUnitCode(adUnitCode);
for (let i = 0; i < adSlots.length; i++) {
if (isMatchingAdSlot(adSlots[i])) {
const id = adSlots[i].getSlotElementId();
utils.logInfo(`[33Across Adapter] Map ad unit path to HTML element id: '${adUnitCode}' -> ${id}`);
return id;
}
}
}
utils.logWarn(`[33Across Adapter] Unable to locate element for ad unit code: '${adUnitCode}'`);
return null;
}
function _getAdSlotHTMLElement(adUnitCode) {
return document.getElementById(adUnitCode) ||
document.getElementById(_mapAdUnitPathToElementId(adUnitCode));
}
function _getMinSize(sizes) {
return sizes.reduce((min, size) => size.h * size.w < min.h * min.w ? size : min);
}
function _getBoundingBox(element, { w, h } = {}) {
let { width, height, left, top, right, bottom } = element.getBoundingClientRect();
if ((width === 0 || height === 0) && w && h) {
width = w;
height = h;
right = left + w;
bottom = top + h;
}
return { width, height, left, top, right, bottom };
}
function _getIntersectionOfRects(rects) {
const bbox = {
left: rects[0].left,
right: rects[0].right,
top: rects[0].top,
bottom: rects[0].bottom
};
for (let i = 1; i < rects.length; ++i) {
bbox.left = Math.max(bbox.left, rects[i].left);
bbox.right = Math.min(bbox.right, rects[i].right);
if (bbox.left >= bbox.right) {
return null;
}
bbox.top = Math.max(bbox.top, rects[i].top);
bbox.bottom = Math.min(bbox.bottom, rects[i].bottom);
if (bbox.top >= bbox.bottom) {
return null;
}
}
bbox.width = bbox.right - bbox.left;
bbox.height = bbox.bottom - bbox.top;
return bbox;
}
function _getPercentInView(element, topWin, { w, h } = {}) {
const elementBoundingBox = _getBoundingBox(element, { w, h });
// Obtain the intersection of the element and the viewport
const elementInViewBoundingBox = _getIntersectionOfRects([ {
left: 0,
top: 0,
right: topWin.innerWidth,
bottom: topWin.innerHeight
}, elementBoundingBox ]);
let elementInViewArea,
elementTotalArea;
if (elementInViewBoundingBox !== null) {
// Some or all of the element is in view
elementInViewArea = elementInViewBoundingBox.width * elementInViewBoundingBox.height;
elementTotalArea = elementBoundingBox.width * elementBoundingBox.height;
return ((elementInViewArea / elementTotalArea) * 100);
}
// No overlap between element and the viewport; therefore, the element
// lies completely out of view
return 0;
}
/**
* Viewability contribution to request..
*/
function contributeViewability(viewabilityAmount) {
const amount = isNaN(viewabilityAmount) ? viewabilityAmount : Math.round(viewabilityAmount);
return {
ttx: {
viewability: {
amount
}
}
};
}
function _isIframe() {
try {
return utils.getWindowSelf() !== utils.getWindowTop();
} catch (e) {
return true;
}
}
// **************************** INTERPRET RESPONSE ******************************** //
// NOTE: At this point, the response from 33exchange will only ever contain one bid
// i.e. the highest bid
function interpretResponse(serverResponse, bidRequest) {
const bidResponses = [];
// If there are bids, look at the first bid of the first seatbid (see NOTE above for assumption about ttx)
if (serverResponse.body.seatbid.length > 0 && serverResponse.body.seatbid[0].bid.length > 0) {
bidResponses.push(_createBidResponse(serverResponse.body));
}
return bidResponses;
}
// All this assumes that only one bid is ever returned by ttx
function _createBidResponse(response) {
const isADomainPresent =
response.seatbid[0].bid[0].adomain && response.seatbid[0].bid[0].adomain.length;
const bid = {
requestId: response.id,
bidderCode: BIDDER_CODE,
cpm: response.seatbid[0].bid[0].price,
width: response.seatbid[0].bid[0].w,
height: response.seatbid[0].bid[0].h,
ad: response.seatbid[0].bid[0].adm,
ttl: response.seatbid[0].bid[0].ttl || 60,
creativeId: response.seatbid[0].bid[0].crid,
mediaType: utils.deepAccess(response.seatbid[0].bid[0], 'ext.ttx.mediaType', BANNER),
currency: response.cur,
netRevenue: true
}
if (isADomainPresent) {
bid.meta = {
advertiserDomains: response.seatbid[0].bid[0].adomain
};
}
if (bid.mediaType === VIDEO) {
const vastType = utils.deepAccess(response.seatbid[0].bid[0], 'ext.ttx.vastType', 'xml');
if (vastType === 'xml') {
bid.vastXml = bid.ad;
} else {
bid.vastUrl = bid.ad;
}
}
return bid;
}
// **************************** USER SYNC *************************** //
// Register one sync per unique guid so long as iframe is enable
// Else no syncs
// For logic on how we handle gdpr data see _createSyncs and module's unit tests
// '33acrossBidAdapter#getUserSyncs'
function getUserSyncs(syncOptions, responses, gdprConsent, uspConsent) {
const syncUrls = (
(syncOptions.iframeEnabled)
? adapterState.uniqueSiteIds.map((siteId) => _createSync({ gdprConsent, uspConsent, siteId }))
: ([])
);
// Clear adapter state of siteID's since we don't need this info anymore.
adapterState.uniqueSiteIds = [];
return syncUrls;
}
// Sync object will always be of type iframe for TTX
function _createSync({ siteId = 'zzz000000000003zzz', gdprConsent = {}, uspConsent }) {
const ttxSettings = config.getConfig('ttxSettings');
const syncUrl = (ttxSettings && ttxSettings.syncUrl) || SYNC_ENDPOINT;
const { consentString, gdprApplies } = gdprConsent;
const sync = {
type: 'iframe',
url: `${syncUrl}&id=${siteId}&gdpr_consent=${encodeURIComponent(consentString)}&us_privacy=${encodeURIComponent(uspConsent)}`
};
if (typeof gdprApplies === 'boolean') {
sync.url += `&gdpr=${Number(gdprApplies)}`;
}
return sync;
}
export const spec = {
NON_MEASURABLE,
code: BIDDER_CODE,
supportedMediaTypes: [ BANNER, VIDEO ],
isBidRequestValid,
buildRequests,
interpretResponse,
getUserSyncs,
};
registerBidder(spec);