prebid/Prebid.js

View on GitHub
modules/ttdBidAdapter.js

Summary

Maintainability
F
4 days
Test Coverage
import * as utils from '../src/utils.js';
import { config } from '../src/config.js';
import { registerBidder } from '../src/adapters/bidderFactory.js';
import { BANNER, VIDEO } from '../src/mediaTypes.js';
import { isNumber } from '../src/utils.js';
import { getConnectionType } from '../libraries/connectionInfo/connectionUtils.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').ServerRequest} ServerRequest
 * @typedef {import('../src/adapters/bidderFactory.js').SyncOptions} SyncOptions
 * @typedef {import('../src/adapters/bidderFactory.js').UserSync} UserSync
 */

const BIDADAPTERVERSION = 'TTD-PREBID-2024.07.26';
const BIDDER_CODE = 'ttd';
const BIDDER_CODE_LONG = 'thetradedesk';
const BIDDER_ENDPOINT = 'https://direct.adsrvr.org/bid/bidder/';
const BIDDER_ENDPOINT_HTTP2 = 'https://d2.adsrvr.org/bid/bidder/';
const USER_SYNC_ENDPOINT = 'https://match.adsrvr.org';

const MEDIA_TYPE = {
  BANNER: 1,
  VIDEO: 2
};

function getExt(firstPartyData) {
  const ext = {
    ver: BIDADAPTERVERSION,
    pbjs: '$prebid.version$',
    keywords: firstPartyData.site?.keywords ? firstPartyData.site.keywords.split(',').map(k => k.trim()) : []
  }
  return {
    ttdprebid: ext
  };
}

function getRegs(bidderRequest) {
  let regs = {};

  if (bidderRequest.gdprConsent && typeof bidderRequest.gdprConsent.gdprApplies === 'boolean') {
    utils.deepSetValue(regs, 'ext.gdpr', bidderRequest.gdprConsent.gdprApplies ? 1 : 0);
  }
  if (bidderRequest.uspConsent) {
    utils.deepSetValue(regs, 'ext.us_privacy', bidderRequest.uspConsent);
  }
  if (config.getConfig('coppa') === true) {
    regs.coppa = 1;
  }
  if (bidderRequest.ortb2?.regs) {
    utils.mergeDeep(regs, bidderRequest.ortb2.regs);
  }

  return regs;
}

function getBidFloor(bid) {
  // value from params takes precedance over value set by Floor Module
  if (bid.params.bidfloor) {
    return bid.params.bidfloor;
  }

  if (!utils.isFn(bid.getFloor)) {
    return null;
  }

  let floor = bid.getFloor({
    currency: 'USD',
    mediaType: '*',
    size: '*'
  });
  if (utils.isPlainObject(floor) && !isNaN(floor.floor) && floor.currency === 'USD') {
    return floor.floor;
  }
  return null;
}

function getSource(validBidRequests, bidderRequest) {
  let source = {
    tid: bidderRequest?.ortb2?.source?.tid,
  };
  if (validBidRequests[0].schain) {
    utils.deepSetValue(source, 'ext.schain', validBidRequests[0].schain);
  }
  return source;
}

function getDevice(firstPartyData) {
  const language = navigator.language || navigator.browserLanguage || navigator.userLanguage || navigator.systemLanguage;
  let device = {
    ua: navigator.userAgent,
    dnt: utils.getDNT() ? 1 : 0,
    language: language,
    connectiontype: getConnectionType()
  };

  utils.mergeDeep(device, firstPartyData.device)

  return device;
};

function getUser(bidderRequest, firstPartyData) {
  let user = {};
  if (bidderRequest.gdprConsent) {
    utils.deepSetValue(user, 'ext.consent', bidderRequest.gdprConsent.consentString);
  }

  if (utils.isStr(utils.deepAccess(bidderRequest, 'bids.0.userId.tdid'))) {
    user.buyeruid = bidderRequest.bids[0].userId.tdid;
  }

  var eids = utils.deepAccess(bidderRequest, 'bids.0.userIdAsEids')
  if (eids && eids.length) {
    utils.deepSetValue(user, 'ext.eids', eids);
  }

  utils.mergeDeep(user, firstPartyData.user)

  return user;
}

function getSite(bidderRequest, firstPartyData) {
  var site = utils.mergeDeep({
    page: utils.deepAccess(bidderRequest, 'refererInfo.page'),
    ref: utils.deepAccess(bidderRequest, 'refererInfo.ref'),
    publisher: {
      id: utils.deepAccess(bidderRequest, 'bids.0.params.publisherId'),
    },
  },
  firstPartyData.site
  );

  var publisherDomain = bidderRequest.refererInfo.domain;
  if (publisherDomain) {
    utils.deepSetValue(site, 'publisher.domain', publisherDomain);
  }
  return site;
}

function getImpression(bidRequest) {
  let impression = {
    id: bidRequest.bidId
  };

  const gpid = utils.deepAccess(bidRequest, 'ortb2Imp.ext.gpid');
  const tagid = gpid || bidRequest.params.placementId;
  if (tagid) {
    impression.tagid = tagid;
  }

  const mediaTypesVideo = utils.deepAccess(bidRequest, 'mediaTypes.video');
  const mediaTypesBanner = utils.deepAccess(bidRequest, 'mediaTypes.banner');

  let mediaTypes = {};
  if (mediaTypesBanner) {
    mediaTypes[BANNER] = banner(bidRequest);
  }
  if (FEATURES.VIDEO && mediaTypesVideo) {
    mediaTypes[VIDEO] = video(bidRequest);
  }

  Object.assign(impression, mediaTypes);

  let bidfloor = getBidFloor(bidRequest);
  if (bidfloor) {
    impression.bidfloor = parseFloat(bidfloor);
    impression.bidfloorcur = 'USD';
  }

  const secure = utils.deepAccess(bidRequest, 'ortb2Imp.secure');
  impression.secure = isNumber(secure) ? secure : 1

  utils.mergeDeep(impression, bidRequest.ortb2Imp)

  return impression;
}

function getSizes(sizes) {
  const sizeStructs = utils.parseSizesInput(sizes)
    .filter(x => x) // sizes that don't conform are returned as null, which we want to ignore
    .map(x => x.split('x'))
    .map(size => {
      return {
        width: parseInt(size[0]),
        height: parseInt(size[1]),
      }
    });

  return sizeStructs;
}

function banner(bid) {
  const sizes = getSizes(bid.mediaTypes.banner.sizes).map(x => {
    return {
      w: x.width,
      h: x.height,
    }
  });
  const pos = parseInt(utils.deepAccess(bid, 'mediaTypes.banner.pos'));
  const expdir = utils.deepAccess(bid, 'params.banner.expdir');
  let optionalParams = {};
  if (pos) {
    optionalParams.pos = pos;
  }
  if (expdir && Array.isArray(expdir)) {
    optionalParams.expdir = expdir;
  }

  const banner = Object.assign(
    {
      w: sizes[0].w,
      h: sizes[0].h,
      format: sizes,
    },
    optionalParams);

  const battr = utils.deepAccess(bid, 'ortb2Imp.banner.battr');
  if (battr) {
    banner.battr = battr;
  }

  return banner;
}

function video(bid) {
  if (FEATURES.VIDEO) {
    let minduration = utils.deepAccess(bid, 'mediaTypes.video.minduration');
    const maxduration = utils.deepAccess(bid, 'mediaTypes.video.maxduration');
    const playerSize = utils.deepAccess(bid, 'mediaTypes.video.playerSize');
    const api = utils.deepAccess(bid, 'mediaTypes.video.api');
    const mimes = utils.deepAccess(bid, 'mediaTypes.video.mimes');
    const placement = utils.deepAccess(bid, 'mediaTypes.video.placement');
    const plcmt = utils.deepAccess(bid, 'mediaTypes.video.plcmt');
    const protocols = utils.deepAccess(bid, 'mediaTypes.video.protocols');
    const playbackmethod = utils.deepAccess(bid, 'mediaTypes.video.playbackmethod');
    const pos = utils.deepAccess(bid, 'mediaTypes.video.pos');
    const startdelay = utils.deepAccess(bid, 'mediaTypes.video.startdelay');
    const skip = utils.deepAccess(bid, 'mediaTypes.video.skip');
    const skipmin = utils.deepAccess(bid, 'mediaTypes.video.skipmin');
    const skipafter = utils.deepAccess(bid, 'mediaTypes.video.skipafter');
    const minbitrate = utils.deepAccess(bid, 'mediaTypes.video.minbitrate');
    const maxbitrate = utils.deepAccess(bid, 'mediaTypes.video.maxbitrate');

    if (!minduration || !utils.isInteger(minduration)) {
      minduration = 0;
    }
    let video = {
      minduration: minduration,
      maxduration: maxduration,
      api: api,
      mimes: mimes,
      placement: placement,
      protocols: protocols
    };

    if (typeof playerSize !== 'undefined') {
      if (utils.isArray(playerSize[0])) {
        video.w = parseInt(playerSize[0][0]);
        video.h = parseInt(playerSize[0][1]);
      } else if (utils.isNumber(playerSize[0])) {
        video.w = parseInt(playerSize[0]);
        video.h = parseInt(playerSize[1]);
      }
    }

    if (playbackmethod) {
      video.playbackmethod = playbackmethod;
    }
    if (plcmt) {
      video.plcmt = plcmt;
    }
    if (pos) {
      video.pos = pos;
    }
    if (startdelay && utils.isInteger(startdelay)) {
      video.startdelay = startdelay;
    }
    if (skip && (skip === 0 || skip === 1)) {
      video.skip = skip;
    }
    if (skipmin && utils.isInteger(skipmin)) {
      video.skipmin = skipmin;
    }
    if (skipafter && utils.isInteger(skipafter)) {
      video.skipafter = skipafter;
    }
    if (minbitrate && utils.isInteger(minbitrate)) {
      video.minbitrate = minbitrate;
    }
    if (maxbitrate && utils.isInteger(maxbitrate)) {
      video.maxbitrate = maxbitrate;
    }

    const battr = utils.deepAccess(bid, 'ortb2Imp.video.battr');
    if (battr) {
      video.battr = battr;
    }

    return video;
  }
}

function selectEndpoint(params) {
  if (params.useHttp2) {
    return BIDDER_ENDPOINT_HTTP2;
  }
  return BIDDER_ENDPOINT;
}

export const spec = {
  code: BIDDER_CODE,
  gvlid: 21,
  aliases: [BIDDER_CODE_LONG],
  supportedMediaTypes: [BANNER, VIDEO],

  /**
   * Determines whether or not 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: function (bid) {
    const alphaRegex = /^[\w+]+$/;

    // required parameters
    if (!bid || !bid.params) {
      utils.logWarn(BIDDER_CODE + ': Missing bid parameters');
      return false;
    }
    if (!bid.params.supplySourceId) {
      utils.logWarn(BIDDER_CODE + ': Missing required parameter params.supplySourceId');
      return false;
    }
    if (!alphaRegex.test(bid.params.supplySourceId)) {
      utils.logWarn(BIDDER_CODE + ': supplySourceId must only contain alphabetic characters');
      return false;
    }
    if (!bid.params.publisherId) {
      utils.logWarn(BIDDER_CODE + ': Missing required parameter params.publisherId');
      return false;
    }
    if (bid.params.publisherId.length > 32) {
      utils.logWarn(BIDDER_CODE + ': params.publisherId must be 32 characters or less');
      return false;
    }

    // optional parameters
    if (bid.params.bidfloor && isNaN(parseFloat(bid.params.bidfloor))) {
      return false;
    }

    const gpid = utils.deepAccess(bid, 'ortb2Imp.ext.gpid');
    if (!bid.params.placementId && !gpid) {
      utils.logWarn(BIDDER_CODE + ': one of params.placementId or gpid (via the GPT module https://docs.prebid.org/dev-docs/modules/gpt-pre-auction.html) must be passed');
      return false;
    }

    const mediaTypesBanner = utils.deepAccess(bid, 'mediaTypes.banner');
    const mediaTypesVideo = utils.deepAccess(bid, 'mediaTypes.video');

    if (!mediaTypesBanner && !mediaTypesVideo) {
      utils.logWarn(BIDDER_CODE + ': one of mediaTypes.banner or mediaTypes.video must be passed');
      return false;
    }

    if (FEATURES.VIDEO && mediaTypesVideo) {
      if (!mediaTypesVideo.maxduration || !utils.isInteger(mediaTypesVideo.maxduration)) {
        utils.logWarn(BIDDER_CODE + ': mediaTypes.video.maxduration must be set to the maximum video ad duration in seconds');
        return false;
      }
      if (!mediaTypesVideo.api || mediaTypesVideo.api.length === 0) {
        utils.logWarn(BIDDER_CODE + ': mediaTypes.video.api should be an array of supported api frameworks. See the Open RTB v2.5 spec for valid values');
        return false;
      }
      if (!mediaTypesVideo.mimes || mediaTypesVideo.mimes.length === 0) {
        utils.logWarn(BIDDER_CODE + ': mediaTypes.video.mimes should be an array of supported mime types');
        return false;
      }
      if (!mediaTypesVideo.protocols) {
        utils.logWarn(BIDDER_CODE + ': mediaTypes.video.protocols should be an array of supported protocols. See the Open RTB v2.5 spec for valid values');
        return false;
      }
    }

    return true;
  },

  /**
   * Make a server request from the list of BidRequests.
   *
   * @param {BidRequest[]} an array of validBidRequests
   * @param {*} bidderRequest
   * @return {ServerRequest} Info describing the request to the server.
   */
  buildRequests: function (validBidRequests, bidderRequest) {
    const firstPartyData = bidderRequest.ortb2 || {};
    let topLevel = {
      id: bidderRequest.bidderRequestId,
      imp: validBidRequests.map(bidRequest => getImpression(bidRequest)),
      site: getSite(bidderRequest, firstPartyData),
      device: getDevice(firstPartyData),
      user: getUser(bidderRequest, firstPartyData),
      at: 1,
      cur: ['USD'],
      regs: getRegs(bidderRequest),
      source: getSource(validBidRequests, bidderRequest),
      ext: getExt(firstPartyData)
    }

    if (firstPartyData && firstPartyData.bcat) {
      topLevel.bcat = firstPartyData.bcat;
    }

    if (firstPartyData && firstPartyData.badv) {
      topLevel.badv = firstPartyData.badv;
    }

    if (firstPartyData && firstPartyData.app) {
      topLevel.app = firstPartyData.app
    }

    if (firstPartyData && firstPartyData.pmp) {
      topLevel.pmp = firstPartyData.pmp
    }

    let url = selectEndpoint(bidderRequest.bids[0].params) + bidderRequest.bids[0].params.supplySourceId;

    let serverRequest = {
      method: 'POST',
      url: url,
      data: topLevel,
      options: {
        withCredentials: true
      }
    };

    return serverRequest;
  },

  /**
   * Format responses as Prebid bid responses
   *
   * Each bid can have the following elements:
   * - requestId (required)
   * - cpm (required)
   * - width (required)
   * - height (required)
   * - ad (required)
   * - ttl (required)
   * - creativeId (required)
   * - netRevenue (required)
   * - currency (required)
   * - vastUrl
   * - vastImpUrl
   * - vastXml
   * - dealId
   *
   * @param {ttdResponseObj} bidResponse A successful response from ttd.
   * @param {ServerRequest} serverRequest The result of buildRequests() that lead to this response.
   * @return {Bid[]} An array of formatted bids.
   */
  interpretResponse: function (response, serverRequest) {
    let seatBidsInResponse = utils.deepAccess(response, 'body.seatbid');
    const currency = utils.deepAccess(response, 'body.cur');
    if (!seatBidsInResponse || seatBidsInResponse.length === 0) {
      return [];
    }
    let bidResponses = [];
    let requestedImpressions = utils.deepAccess(serverRequest, 'data.imp');

    seatBidsInResponse.forEach(seatBid => {
      seatBid.bid.forEach(bid => {
        let matchingRequestedImpression = requestedImpressions.find(imp => imp.id === bid.impid);

        const cpm = bid.price || 0;
        let bidResponse = {
          requestId: bid.impid,
          cpm: cpm,
          creativeId: bid.crid,
          dealId: bid.dealid || null,
          currency: currency || 'USD',
          netRevenue: true,
          ttl: bid.ttl || 360,
          meta: {},
        };

        if (bid.adomain && bid.adomain.length > 0) {
          bidResponse.meta.advertiserDomains = bid.adomain;
        }

        if (bid.ext.mediatype === MEDIA_TYPE.BANNER) {
          Object.assign(
            bidResponse,
            {
              width: bid.w,
              height: bid.h,
              ad: utils.replaceAuctionPrice(bid.adm, cpm),
              mediaType: BANNER
            }
          );
        } else if (FEATURES.VIDEO && bid.ext.mediatype === MEDIA_TYPE.VIDEO) {
          Object.assign(
            bidResponse,
            {
              width: matchingRequestedImpression.video.w,
              height: matchingRequestedImpression.video.h,
              mediaType: VIDEO
            }
          );
          if (bid.nurl) {
            bidResponse.vastUrl = utils.replaceAuctionPrice(bid.nurl, cpm);
          } else {
            bidResponse.vastXml = utils.replaceAuctionPrice(bid.adm, cpm);
          }
        }

        bidResponses.push(bidResponse);
      });
    });

    return bidResponses;
  },

  /**
   * 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.
   * @param {gdprConsent} gdprConsent GDPR consent object
   * @param {uspConsent} uspConsent USP consent object
   * @return {UserSync[]} The user syncs which should be dropped.
   */
  getUserSyncs: function(syncOptions, serverResponses, gdprConsent = {}, uspConsent = '') {
    const syncs = [];

    let gdprParams = `&gdpr=${gdprConsent.gdprApplies ? 1 : 0}&gdpr_consent=${encodeURIComponent(gdprConsent.consentString)}`;

    let url = `${USER_SYNC_ENDPOINT}/track/usersync?us_privacy=${encodeURIComponent(uspConsent)}${gdprParams}`;

    if (syncOptions.pixelEnabled) {
      syncs.push({
        type: 'image',
        url: url + '&ust=image'
      });
    } else if (syncOptions.iframeEnabled) {
      syncs.push({
        type: 'iframe',
        url: url + '&ust=iframe'
      });
    }
    return syncs;
  },
};

registerBidder(spec)