prebid/Prebid.js

View on GitHub
modules/smaatoBidAdapter.js

Summary

Maintainability
F
4 days
Test Coverage
import {deepAccess, deepSetValue, getDNT, isEmpty, isNumber, logError, logInfo} from '../src/utils.js';
import {find} from '../src/polyfill.js';
import {registerBidder} from '../src/adapters/bidderFactory.js';
import {config} from '../src/config.js';
import {ADPOD, BANNER, NATIVE, VIDEO} from '../src/mediaTypes.js';
import {NATIVE_IMAGE_TYPES} from '../src/constants.js';
import {getAdUnitSizes} from '../libraries/sizeUtils/sizeUtils.js';
import {fill} from '../libraries/appnexusUtils/anUtils.js';
import {chunk} from '../libraries/chunk/chunk.js';
import {ortbConverter} from '../libraries/ortbConverter/converter.js';

/**
 * @typedef {import('../src/adapters/bidderFactory.js').BidRequest} BidRequest
 * @typedef {import('../src/adapters/bidderFactory.js').Bid} Bid
 * @typedef {import('../src/adapters/bidderFactory.js').ServerResponse} ServerResponse
 * @typedef {import('../src/adapters/bidderFactory.js').SyncOptions} SyncOptions
 * @typedef {import('../src/adapters/bidderFactory.js').UserSync} UserSync
 */

const BIDDER_CODE = 'smaato';
const SMAATO_ENDPOINT = 'https://prebid.ad.smaato.net/oapi/prebid';
const SMAATO_CLIENT = 'prebid_js_$prebid.version$_3.0'
const TTL = 300;
const CURRENCY = 'USD';
const SUPPORTED_MEDIA_TYPES = [BANNER, VIDEO, NATIVE];

export const spec = {
  code: BIDDER_CODE,
  supportedMediaTypes: SUPPORTED_MEDIA_TYPES,
  gvlid: 82,

  /**
   * Determines whether the given bid request is valid.
   *
   * @param {BidRequest} bid The bid params to validate.
   * @return boolean True if this is a valid bid, and false otherwise.
   */
  isBidRequestValid: (bid) => {
    if (typeof bid.params !== 'object') {
      logError('[SMAATO] Missing params object');
      return false;
    }

    if (typeof bid.params.publisherId !== 'string') {
      logError('[SMAATO] Missing mandatory publisherId param');
      return false;
    }

    if (deepAccess(bid, 'mediaTypes.video.context') === ADPOD) {
      logInfo('[SMAATO] Verifying adpod bid request');

      if (typeof bid.params.adbreakId !== 'string') {
        logError('[SMAATO] Missing for adpod request mandatory adbreakId param');
        return false;
      }

      if (bid.params.adspaceId) {
        logError('[SMAATO] The adspaceId param is not allowed in an adpod bid request');
        return false;
      }
    } else {
      logInfo('[SMAATO] Verifying a non adpod bid request');

      if (typeof bid.params.adspaceId !== 'string') {
        logError('[SMAATO] Missing mandatory adspaceId param');
        return false;
      }

      if (bid.params.adbreakId) {
        logError('[SMAATO] The adbreakId param is only allowed in an adpod bid request');
        return false;
      }
    }

    logInfo('[SMAATO] Verification done, all good');
    return true;
  },

  buildRequests: (bidRequests, bidderRequest) => {
    logInfo('[SMAATO] Client version:', SMAATO_CLIENT);

    let requests = [];
    bidRequests.forEach(bid => {
      // separate requests per mediaType
      SUPPORTED_MEDIA_TYPES.forEach(mediaType => {
        if ((bid.mediaTypes && bid.mediaTypes[mediaType]) || (mediaType === NATIVE && bid.nativeOrtbRequest)) {
          const data = converter.toORTB({bidderRequest, bidRequests: [bid], context: {mediaType}});
          requests.push({
            method: 'POST',
            url: bid.params.endpoint || SMAATO_ENDPOINT,
            data: JSON.stringify(data),
            options: {
              withCredentials: true,
              crossOrigin: true,
            },
            bidderRequest
          })
        }
      });
    });

    return requests;
  },
  /**
   * Unpack the response from the server into a list of bids.
   *
   * @param {ServerResponse} serverResponse A successful response from the server.
   * @param {BidRequest} bidRequest
   * @return {Bid[]} An array of bids which were nested inside the server.
   */
  interpretResponse: (serverResponse, bidRequest) => {
    // response is empty (HTTP 204)
    if (isEmpty(serverResponse.body)) {
      logInfo('[SMAATO] Empty response body HTTP 204, no bids');
      return []; // no bids
    }

    const serverResponseHeaders = serverResponse.headers;

    const smtExpires = serverResponseHeaders.get('X-SMT-Expires');
    logInfo('[SMAATO] Expires:', smtExpires);
    const ttlInSec = smtExpires ? Math.floor((smtExpires - Date.now()) / 1000) : 300;

    const response = serverResponse.body;
    logInfo('[SMAATO] OpenRTB Response:', response);

    const smtAdType = serverResponseHeaders.get('X-SMT-ADTYPE');
    const bids = [];
    response.seatbid.forEach(seatbid => {
      seatbid.bid.forEach(bid => {
        let resultingBid = {
          requestId: bid.impid,
          cpm: bid.price || 0,
          width: bid.w,
          height: bid.h,
          ttl: ttlInSec,
          creativeId: bid.crid,
          dealId: bid.dealid || null,
          netRevenue: deepAccess(bid, 'ext.net', true),
          currency: CURRENCY,
          meta: {
            advertiserDomains: bid.adomain,
            networkName: bid.bidderName,
            agencyId: seatbid.seat
          }
        };

        const videoContext = deepAccess(JSON.parse(bidRequest.data).imp[0], 'video.ext.context');
        if (videoContext === ADPOD) {
          resultingBid.vastXml = bid.adm;
          resultingBid.mediaType = VIDEO;
          if (config.getConfig('adpod.brandCategoryExclusion')) {
            resultingBid.meta.primaryCatId = bid.cat[0];
          }
          resultingBid.video = {
            context: ADPOD,
            durationSeconds: bid.ext.duration
          };
          bids.push(resultingBid);
        } else {
          switch (smtAdType) {
            case 'Img':
            case 'Richmedia':
              resultingBid.ad = createBannerAd(bid);
              resultingBid.mediaType = BANNER;
              bids.push(resultingBid);
              break;
            case 'Video':
              resultingBid.vastXml = bid.adm;
              resultingBid.mediaType = VIDEO;
              bids.push(resultingBid);
              break;
            case 'Native':
              resultingBid.native = createNativeAd(bid.adm);
              resultingBid.mediaType = NATIVE;
              bids.push(resultingBid);
              break;
            default:
              logInfo('[SMAATO] Invalid ad type:', smtAdType);
          }
        }
        resultingBid.meta.mediaType = resultingBid.mediaType;
      });
    });

    logInfo('[SMAATO] Prebid bids:', bids);
    return bids;
  },

  /**
   * Register the user sync pixels which should be dropped after the auction.
   *
   * @param {SyncOptions} syncOptions Which user syncs are allowed?
   * @param {ServerResponse[]} serverResponses List of server's responses.
   * @return {UserSync[]} The user syncs which should be dropped.
   */
  getUserSyncs: (syncOptions, serverResponses, gdprConsent, uspConsent) => {
    return [];
  }
}
registerBidder(spec);

const converter = ortbConverter({
  context: {
    netRevenue: true,
    ttl: TTL,
    currency: CURRENCY
  },
  request(buildRequest, imps, bidderRequest, context) {
    function isGdprApplicable() {
      return bidderRequest.gdprConsent && bidderRequest.gdprConsent.gdprApplies;
    }

    const request = buildRequest(imps, bidderRequest, context);
    const bidRequest = context.bidRequests[0];
    let siteContent;
    const mediaType = context.mediaType;
    if (mediaType === VIDEO) {
      const videoParams = bidRequest.mediaTypes[VIDEO];
      if (videoParams.context === ADPOD) {
        request.imp = createAdPodImp(request.imp[0], videoParams);
        siteContent = addOptionalAdpodParameters(videoParams);
      }
    }

    request.at = 1;

    if (request.user) {
      if (isGdprApplicable()) {
        deepSetValue(request.user, 'ext.consent', bidderRequest.gdprConsent.consentString);
      }
    } else {
      const eids = deepAccess(bidRequest, 'userIdAsEids');
      request.user = {
        ext: {
          consent: isGdprApplicable() ? bidderRequest.gdprConsent.consentString : null,
          eids: (eids && eids.length) ? eids : null
        }
      }
    }

    if (request.site) {
      request.site.id = window.location.hostname
      if (siteContent) {
        request.site.content = siteContent;
      }
    } else {
      request.site = {
        id: window.location.hostname,
        domain: bidderRequest.refererInfo.domain || window.location.hostname,
        page: bidderRequest.refererInfo.page || window.location.href,
        ref: bidderRequest.refererInfo.ref,
        content: siteContent || null
      }
    }
    deepSetValue(request.site, 'publisher.id', bidRequest.params.publisherId);

    if (request.regs) {
      if (isGdprApplicable()) {
        deepSetValue(request.regs, 'ext.gdpr', bidderRequest.gdprConsent.gdprApplies ? 1 : 0);
      }
      if (bidderRequest.uspConsent !== undefined) {
        deepSetValue(request.regs, 'ext.us_privacy', bidderRequest.uspConsent);
      }
      if (request.regs?.gpp) {
        deepSetValue(request.regs, 'ext.gpp', request.regs.gpp);
        deepSetValue(request.regs, 'ext.gpp_sid', request.regs.gpp_sid);
      }
    } else {
      request.regs = {
        coppa: config.getConfig('coppa') === true ? 1 : 0,
        ext: {
          gdpr: isGdprApplicable() ? bidderRequest.gdprConsent.gdprApplies ? 1 : 0 : null,
          us_privacy: bidderRequest.uspConsent
        }
      }
    }

    if (request.device) {
      if (bidRequest.params.app) {
        if (!deepAccess(request.device, 'geo')) {
          const geo = deepAccess(bidRequest, 'params.app.geo');
          deepSetValue(request.device, 'geo', geo);
        }
        if (!deepAccess(request.device, 'ifa')) {
          const ifa = deepAccess(bidRequest, 'params.app.ifa');
          deepSetValue(request.device, 'ifa', ifa);
        }
      }
    } else {
      request.device = {
        language: (navigator && navigator.language) ? navigator.language.split('-')[0] : '',
        ua: navigator.userAgent,
        dnt: getDNT() ? 1 : 0,
        h: screen.height,
        w: screen.width
      }
      if (!deepAccess(request.device, 'geo')) {
        const geo = deepAccess(bidRequest, 'params.app.geo');
        deepSetValue(request.device, 'geo', geo);
      }
      if (!deepAccess(request.device, 'ifa')) {
        const ifa = deepAccess(bidRequest, 'params.app.ifa');
        deepSetValue(request.device, 'ifa', ifa);
      }
    }

    request.source = {
      ext: {
        schain: bidRequest.schain
      }
    };
    request.ext = {
      client: SMAATO_CLIENT
    }
    return request;
  },

  imp(buildImp, bidRequest, context) {
    const imp = buildImp(bidRequest, context);
    deepSetValue(imp, 'tagid', bidRequest.params.adbreakId || bidRequest.params.adspaceId);
    if (imp.bidfloorcur && imp.bidfloorcur !== CURRENCY) {
      delete imp.bidfloor;
      delete imp.bidfloorcur;
    }
    return imp;
  },

  overrides: {
    imp: {
      banner(orig, imp, bidRequest, context) {
        const mediaType = context.mediaType;

        if (mediaType === BANNER) {
          imp.bidfloor = getBidFloor(bidRequest, BANNER, getAdUnitSizes(bidRequest));
        }

        orig(imp, bidRequest, context);
      },

      video(orig, imp, bidRequest, context) {
        const mediaType = context.mediaType;
        if (mediaType === VIDEO) {
          const videoParams = bidRequest.mediaTypes[VIDEO];
          imp.bidfloor = getBidFloor(bidRequest, VIDEO, videoParams.playerSize);
          if (videoParams.context !== ADPOD) {
            deepSetValue(imp, 'video.ext', {
              rewarded: videoParams.ext && videoParams.ext.rewarded ? videoParams.ext.rewarded : 0
            })
          }
        }

        orig(imp, bidRequest, context);
      },

      native(orig, imp, bidRequest, context) {
        const mediaType = context.mediaType;

        if (mediaType === NATIVE) {
          imp.bidfloor = getBidFloor(bidRequest, NATIVE, getNativeMainImageSize(bidRequest.nativeOrtbRequest));
        }

        orig(imp, bidRequest, context);
      }
    },
  }
});

const createBannerAd = (bid) => {
  let clickEvent = '';
  if (bid.ext && bid.ext.curls) {
    let clicks = ''
    bid.ext.curls.forEach(src => {
      clicks += `fetch(decodeURIComponent('${encodeURIComponent(src)}'), {cache: 'no-cache'});`;
    })
    clickEvent = `onclick="${clicks}"`
  }

  return `<div style="cursor:pointer" ${clickEvent}>${bid.adm}</div>`;
};

const createNativeAd = (adm) => {
  const nativeResponse = JSON.parse(adm).native;
  return {
    ortb: nativeResponse
  }
};

function getNativeMainImageSize(nativeRequest) {
  const mainImage = find(nativeRequest.assets, asset => asset.hasOwnProperty('img') && asset.img.type === NATIVE_IMAGE_TYPES.MAIN)
  if (mainImage) {
    if (isNumber(mainImage.img.w) && isNumber(mainImage.img.h)) {
      return [[mainImage.img.w, mainImage.img.h]]
    }
    if (isNumber(mainImage.img.wmin) && isNumber(mainImage.img.hmin)) {
      return [[mainImage.img.wmin, mainImage.img.hmin]]
    }
  }
  return []
}

function createAdPodImp(imp, videoMediaType) {
  const bce = config.getConfig('adpod.brandCategoryExclusion')
  imp.video.ext = {
    context: ADPOD,
    brandcategoryexclusion: bce !== undefined && bce
  };

  const numberOfPlacements = getAdPodNumberOfPlacements(videoMediaType)
  let imps = fill(imp, numberOfPlacements)

  const durationRangeSec = videoMediaType.durationRangeSec
  if (videoMediaType.requireExactDuration) {
    // equal distribution of numberOfPlacement over all available durations
    const divider = Math.ceil(numberOfPlacements / durationRangeSec.length)
    const chunked = chunk(imps, divider)

    // each configured duration is set as min/maxduration for a subset of requests
    durationRangeSec.forEach((duration, index) => {
      chunked[index].map(imp => {
        const sequence = index + 1;
        imp.video.minduration = duration
        imp.video.maxduration = duration
        imp.video.sequence = sequence
      });
    });
  } else {
    // all maxdurations should be the same
    const maxDuration = Math.max(...durationRangeSec);
    imps.map((imp, index) => {
      const sequence = index + 1;
      imp.video.maxduration = maxDuration
      imp.video.sequence = sequence
    });
  }

  return imps
}

function getAdPodNumberOfPlacements(videoMediaType) {
  const {adPodDurationSec, durationRangeSec, requireExactDuration} = videoMediaType
  const minAllowedDuration = Math.min(...durationRangeSec)
  const numberOfPlacements = Math.floor(adPodDurationSec / minAllowedDuration)

  return requireExactDuration
    ? Math.max(numberOfPlacements, durationRangeSec.length)
    : numberOfPlacements
}

const addOptionalAdpodParameters = (videoMediaType) => {
  const content = {}

  if (videoMediaType.tvSeriesName) {
    content.series = videoMediaType.tvSeriesName
  }
  if (videoMediaType.tvEpisodeName) {
    content.title = videoMediaType.tvEpisodeName
  }
  if (typeof videoMediaType.tvSeasonNumber === 'number') {
    content.season = videoMediaType.tvSeasonNumber.toString() // conversion to string as in OpenRTB season is a string
  }
  if (typeof videoMediaType.tvEpisodeNumber === 'number') {
    content.episode = videoMediaType.tvEpisodeNumber
  }
  if (typeof videoMediaType.contentLengthSec === 'number') {
    content.len = videoMediaType.contentLengthSec
  }
  if (videoMediaType.contentMode && ['live', 'on-demand'].indexOf(videoMediaType.contentMode) >= 0) {
    content.livestream = videoMediaType.contentMode === 'live' ? 1 : 0
  }

  if (!isEmpty(content)) {
    return content
  }
}

function getBidFloor(bidRequest, mediaType, sizes) {
  if (typeof bidRequest.getFloor === 'function') {
    const size = sizes.length === 1 ? sizes[0] : '*';
    const floor = bidRequest.getFloor({currency: CURRENCY, mediaType: mediaType, size: size});
    if (floor && !isNaN(floor.floor) && (floor.currency === CURRENCY)) {
      return floor.floor;
    }
  }
}