prebid/Prebid.js

View on GitHub
modules/33acrossBidAdapter.js

Summary

Maintainability
F
6 days
Test Coverage
import {registerBidder} from '../src/adapters/bidderFactory.js';
import {config} from '../src/config.js';
import {
  deepAccess,
  getWindowSelf,
  getWindowTop,
  isArray,
  isGptPubadsDefined,
  logInfo,
  logWarn,
  mergeDeep,
  pick,
  uniques
} from '../src/utils.js';
import {BANNER, VIDEO} from '../src/mediaTypes.js';
import {isSlotMatchingAdUnitCode} from '../libraries/gptUtils/gptUtils.js';

// **************************** UTILS *************************** //
const BIDDER_CODE = '33across';
const BIDDER_ALIASES = ['33across_mgni'];
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 GVLID = 58;
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';

function getTTXConfig() {
  const ttxSettings = Object.assign({},
    config.getConfig('ttxSettings')
  );

  return ttxSettings;
}

// **************************** VALIDATION *************************** //
function isBidRequestValid(bid) {
  return (
    _validateBasic(bid) &&
    _validateBanner(bid) &&
    _validateVideo(bid)
  );
}

function _validateBasic(bid) {
  const invalidBidderName = bid.bidder !== BIDDER_CODE && !BIDDER_ALIASES.includes(bid.bidder);

  if (invalidBidderName || !bid.params) {
    return false;
  }

  if (!_validateGUID(bid)) {
    return false;
  }

  return true;
}

function _validateGUID(bid) {
  const siteID = deepAccess(bid, 'params.siteId', '') || '';
  if (siteID.trim().match(GUID_PATTERN) === null) {
    return false;
  }

  return true;
}

function _validateBanner(bid) {
  const banner = 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 = deepAccess(bid, 'mediaTypes.video');
  const videoBidderParams = 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 {
    ttxSettings,
    gdprConsent,
    uspConsent,
    gppConsent,
    pageUrl,
    referer
  } = _buildRequestParams(bidRequests, bidderRequest);

  const groupedRequests = _buildRequestGroups(ttxSettings, bidRequests);

  const serverRequests = [];

  for (const key in groupedRequests) {
    serverRequests.push(
      _createServerRequest({
        bidRequests: groupedRequests[key],
        gdprConsent,
        uspConsent,
        gppConsent,
        pageUrl,
        referer,
        ttxSettings,
        bidderRequest,
      })
    )
  }

  return serverRequests;
}

function _buildRequestParams(bidRequests, bidderRequest) {
  const ttxSettings = getTTXConfig();

  const gdprConsent = Object.assign({
    consentString: undefined,
    gdprApplies: false
  }, bidderRequest && bidderRequest.gdprConsent);

  adapterState.uniqueSiteIds = bidRequests.map(req => req.params.siteId).filter(uniques);

  return {
    ttxSettings,
    gdprConsent,
    uspConsent: bidderRequest?.uspConsent,
    gppConsent: bidderRequest?.gppConsent,
    pageUrl: bidderRequest?.refererInfo?.page,
    referer: bidderRequest?.refererInfo?.ref
  }
}

function _buildRequestGroups(ttxSettings, bidRequests) {
  const bidRequestsComplete = bidRequests.map(_inferProduct);
  const enableSRAMode = ttxSettings && ttxSettings.enableSRAMode;
  const keyFunc = (enableSRAMode === true) ? _getSRAKey : _getMRAKey;

  return _groupBidRequests(bidRequestsComplete, keyFunc);
}

function _groupBidRequests(bidRequests, keyFunc) {
  const groupedRequests = {};

  bidRequests.forEach((req) => {
    const key = keyFunc(req);

    groupedRequests[key] = groupedRequests[key] || [];
    groupedRequests[key].push(req);
  });

  return groupedRequests;
}

function _getSRAKey(bidRequest) {
  return `${bidRequest.params.siteId}:${bidRequest.params.productId}`;
}

function _getMRAKey(bidRequest) {
  return `${bidRequest.bidId}`;
}

// Infer the necessary data from valid bid for a minimal ttxRequest and create HTTP request
function _createServerRequest({ bidRequests, gdprConsent = {}, uspConsent, gppConsent = {}, pageUrl, referer, ttxSettings, bidderRequest }) {
  const ttxRequest = {};
  const firstBidRequest = bidRequests[0];
  const { siteId, test } = firstBidRequest.params;
  const coppaValue = config.getConfig('coppa');

  /*
   * Infer data for the request payload
   */
  ttxRequest.imp = [];

  bidRequests.forEach((req) => {
    ttxRequest.imp.push(_buildImpORTB(req));
  });

  ttxRequest.site = { id: siteId };
  ttxRequest.device = _buildDeviceORTB(firstBidRequest.ortb2?.device);

  if (pageUrl) {
    ttxRequest.site.page = pageUrl;
  }

  if (referer) {
    ttxRequest.site.ref = referer;
  }

  ttxRequest.id = bidderRequest?.bidderRequestId;

  if (gdprConsent.consentString) {
    ttxRequest.user = setExtensions(ttxRequest.user, {
      'consent': gdprConsent.consentString
    });
  }

  if (Array.isArray(firstBidRequest.userIdAsEids) && firstBidRequest.userIdAsEids.length > 0) {
    ttxRequest.user = setExtensions(ttxRequest.user, {
      'eids': firstBidRequest.userIdAsEids
    });
  }

  ttxRequest.regs = setExtensions(ttxRequest.regs, {
    'gdpr': Number(gdprConsent.gdprApplies)
  });

  if (uspConsent) {
    ttxRequest.regs = setExtensions(ttxRequest.regs, {
      'us_privacy': uspConsent
    });
  }

  if (gppConsent.gppString) {
    Object.assign(ttxRequest.regs, {
      'gpp': gppConsent.gppString,
      'gpp_sid': gppConsent.applicableSections
    });
  }

  if (coppaValue !== undefined) {
    ttxRequest.regs.coppa = Number(!!coppaValue);
  }

  ttxRequest.ext = {
    ttx: {
      prebidStartedAt: Date.now(),
      caller: [ {
        'name': 'prebidjs',
        'version': '$prebid.version$'
      } ]
    }
  };

  if (firstBidRequest.schain) {
    ttxRequest.source = setExtensions(ttxRequest.source, {
      'schain': firstBidRequest.schain
    });
  }

  // Finally, set the openRTB 'test' param if this is to be a test bid
  if (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 url = (ttxSettings && ttxSettings.url) || `${END_POINT}?guid=${siteId}`;

  // Return the server request
  return {
    'method': 'POST',
    'url': url,
    'data': JSON.stringify(ttxRequest),
    'options': options
  };
}

// BUILD REQUESTS: SET EXTENSIONS
function setExtensions(obj = {}, extFields) {
  return mergeDeep({}, obj, {
    'ext': extFields
  });
}

// BUILD REQUESTS: IMP
function _buildImpORTB(bidRequest) {
  const gpid = deepAccess(bidRequest, 'ortb2Imp.ext.gpid');

  const imp = {
    id: bidRequest.bidId,
    ext: {
      ttx: {
        prod: deepAccess(bidRequest, 'params.productId')
      },
      ...(gpid ? { gpid } : {})
    }
  };

  if (deepAccess(bidRequest, 'mediaTypes.banner')) {
    imp.banner = {
      ..._buildBannerORTB(bidRequest)
    }
  }

  if (deepAccess(bidRequest, 'mediaTypes.video')) {
    imp.video = _buildVideoORTB(bidRequest);
  }

  return imp;
}

// BUILD REQUESTS: SIZE INFERENCE
function _transformSizes(sizes) {
  if (isArray(sizes) && sizes.length === 2 && !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 _inferProduct(bidRequest) {
  return mergeDeep({}, bidRequest, {
    params: {
      productId: _getProduct(bidRequest)
    }
  });
}

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 = 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, 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 = deepAccess(bidRequest, 'mediaTypes.video', {});
  const videoBidderParams = 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 (isGptPubadsDefined()) {
    // eslint-disable-next-line no-undef
    const adSlots = googletag.pubads().getSlots();
    const isMatchingAdSlot = isSlotMatchingAdUnitCode(adUnitCode);

    for (let i = 0; i < adSlots.length; i++) {
      if (isMatchingAdSlot(adSlots[i])) {
        const id = adSlots[i].getSlotElementId();

        logInfo(`[33Across Adapter] Map ad unit path to HTML element id: '${adUnitCode}' -> ${id}`);

        return id;
      }
    }
  }

  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 getWindowSelf() !== getWindowTop();
  } catch (e) {
    return true;
  }
}

// **************************** INTERPRET RESPONSE ******************************** //
function interpretResponse(serverResponse, bidRequest) {
  const { seatbid, cur = 'USD' } = serverResponse.body;

  if (!isArray(seatbid)) {
    return [];
  }

  // Pick seats with valid bids and convert them into an Array of responses
  // in format expected by Prebid Core
  return seatbid
    .filter((seat) => (
      isArray(seat.bid) &&
      seat.bid.length > 0
    ))
    .reduce((acc, seat) => {
      return acc.concat(
        seat.bid.map((bid) => _createBidResponse(bid, cur))
      );
    }, []);
}

function _createBidResponse(bid, cur) {
  const isADomainPresent =
    bid.adomain && bid.adomain.length;
  const bidResponse = {
    requestId: bid.impid,
    cpm: bid.price,
    width: bid.w,
    height: bid.h,
    ad: bid.adm,
    ttl: bid.ttl || 60,
    creativeId: bid.crid,
    mediaType: deepAccess(bid, 'ext.ttx.mediaType', BANNER),
    currency: cur,
    netRevenue: true
  }

  if (isADomainPresent) {
    bidResponse.meta = {
      advertiserDomains: bid.adomain
    };
  }

  if (bidResponse.mediaType === VIDEO) {
    const vastType = deepAccess(bid, 'ext.ttx.vastType', 'xml');

    if (vastType === 'xml') {
      bidResponse.vastXml = bidResponse.ad;
    } else {
      bidResponse.vastUrl = bidResponse.ad;
    }
  }

  return bidResponse;
}

// **************************** 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, gppConsent) {
  const syncUrls = (
    (syncOptions.iframeEnabled)
      ? adapterState.uniqueSiteIds.map((siteId) => _createSync({ gdprConsent, uspConsent, gppConsent, 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, gppConsent = {} }) {
  const ttxSettings = config.getConfig('ttxSettings');
  const syncUrl = (ttxSettings && ttxSettings.syncUrl) || SYNC_ENDPOINT;

  const { consentString, gdprApplies } = gdprConsent;
  const { gppString = '', applicableSections = [] } = gppConsent;

  const sync = {
    type: 'iframe',
    url: `${syncUrl}&id=${siteId}&gdpr_consent=${encodeURIComponent(consentString)}&us_privacy=${encodeURIComponent(uspConsent)}&gpp=${encodeURIComponent(gppString)}&gpp_sid=${encodeURIComponent(applicableSections.join(','))}`
  };

  if (typeof gdprApplies === 'boolean') {
    sync.url += `&gdpr=${Number(gdprApplies)}`;
  }

  return sync;
}

// BUILD REQUESTS: DEVICE
function _buildDeviceORTB(device = {}) {
  const win = getWindowSelf();
  const deviceProps = {
    ext: {
      ttx: {
        ...getScreenDimensions(),
        pxr: win.devicePixelRatio,
        vp: getViewportDimensions(),
        ah: win.screen.availHeight,
        mtp: win.navigator.maxTouchPoints
      }
    }
  }

  if (device.sua) {
    deviceProps.sua = pick(device.sua, [ 'browsers', 'platform', 'model', 'mobile' ]);
  }

  return deviceProps;
}

function getTopMostAccessibleWindow() {
  let mostAccessibleWindow = getWindowSelf();

  try {
    while (mostAccessibleWindow.parent !== mostAccessibleWindow &&
      mostAccessibleWindow.parent.document) {
      mostAccessibleWindow = mostAccessibleWindow.parent;
    }
  } catch (err) {
    // Do not throw an exception if we can't access the topmost frame.
  }

  return mostAccessibleWindow;
}

function getViewportDimensions() {
  const topWin = getTopMostAccessibleWindow();
  const documentElement = topWin.document.documentElement;

  return {
    w: documentElement.clientWidth,
    h: documentElement.clientHeight,
  };
}

function getScreenDimensions() {
  const {
    innerWidth: windowWidth,
    innerHeight: windowHeight,
    screen
  } = getWindowSelf();

  const [biggerDimension, smallerDimension] = [
    Math.max(screen.width, screen.height),
    Math.min(screen.width, screen.height),
  ];

  if (windowHeight > windowWidth) { // Portrait mode
    return {
      w: smallerDimension,
      h: biggerDimension,
    };
  }

  // Landscape mode
  return {
    w: biggerDimension,
    h: smallerDimension,
  };
}

export const spec = {
  NON_MEASURABLE,

  code: BIDDER_CODE,
  aliases: BIDDER_ALIASES,
  supportedMediaTypes: [ BANNER, VIDEO ],
  gvlid: GVLID,
  isBidRequestValid,
  buildRequests,
  interpretResponse,
  getUserSyncs,
};

registerBidder(spec);