prebid/Prebid.js

View on GitHub
modules/seedtagBidAdapter.js

Summary

Maintainability
C
1 day
Test Coverage
import { registerBidder } from '../src/adapters/bidderFactory.js';
import { config } from '../src/config.js';
import { BANNER, VIDEO } from '../src/mediaTypes.js';
import { _map, isArray, triggerPixel } from '../src/utils.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
 * @typedef {import('../src/adapters/bidderFactory.js').validBidRequests} validBidRequests
 */

const BIDDER_CODE = 'seedtag';
const SEEDTAG_ALIAS = 'st';
const SEEDTAG_SSP_ENDPOINT = 'https://s.seedtag.com/c/hb/bid';
const SEEDTAG_SSP_ONTIMEOUT_ENDPOINT = 'https://s.seedtag.com/se/hb/timeout';
const ALLOWED_DISPLAY_PLACEMENTS = [
  'inScreen',
  'inImage',
  'inArticle',
  'inBanner',
];

// Global Vendor List Id
// https://iabeurope.eu/vendor-list-tcf-v2-0/
const GVLID = 157;

const mediaTypesMap = {
  [BANNER]: 'display',
  [VIDEO]: 'video',
};

const deviceConnection = {
  FIXED: 'fixed',
  MOBILE: 'mobile',
  UNKNOWN: 'unknown',
};

const getConnectionType = () => {
  const connection =
    navigator.connection ||
    navigator.mozConnection ||
    navigator.webkitConnection ||
    {};
  switch (connection.type || connection.effectiveType) {
    case 'wifi':
    case 'ethernet':
      return deviceConnection.FIXED;
    case 'cellular':
    case 'wimax':
      return deviceConnection.MOBILE;
    default:
      const isMobile =
        /iPad|iPhone|iPod/.test(navigator.userAgent) ||
        /android/i.test(navigator.userAgent);
      return isMobile ? deviceConnection.UNKNOWN : deviceConnection.FIXED;
  }
};

function mapMediaType(seedtagMediaType) {
  if (seedtagMediaType === 'display') return BANNER;
  if (seedtagMediaType === 'video') return VIDEO;
  else return seedtagMediaType;
}

function hasVideoMediaType(bid) {
  return !!bid.mediaTypes && !!bid.mediaTypes.video;
}

function hasBannerMediaType(bid) {
  return !!bid.mediaTypes && !!bid.mediaTypes.banner;
}

function hasMandatoryDisplayParams(bid) {
  const p = bid.params;
  return (
    !!p.publisherId &&
    !!p.adUnitId &&
    ALLOWED_DISPLAY_PLACEMENTS.indexOf(p.placement) > -1
  );
}

function hasMandatoryVideoParams(bid) {
  const videoParams = getVideoParams(bid);

  let isValid =
    !!bid.params.publisherId &&
    !!bid.params.adUnitId &&
    hasVideoMediaType(bid) &&
    !!videoParams.playerSize &&
    isArray(videoParams.playerSize) &&
    videoParams.playerSize.length > 0;

  switch (bid.params.placement) {
    // instream accept only video format
    case 'inStream':
      return isValid && videoParams.context === 'instream';
    // outstream accept banner/native/video format
    default:
      return (
        isValid &&
        videoParams.context === 'outstream' &&
        hasBannerMediaType(bid) &&
        hasMandatoryDisplayParams(bid)
      );
  }
}

function buildBidRequest(validBidRequest) {
  const params = validBidRequest.params;
  const mediaTypes = _map(
    Object.keys(validBidRequest.mediaTypes),
    function (pbjsType) {
      return mediaTypesMap[pbjsType];
    }
  );
  const bidRequest = {
    id: validBidRequest.bidId,
    transactionId: validBidRequest.ortb2Imp?.ext?.tid,
    gpid: validBidRequest.ortb2Imp?.ext?.gpid,
    sizes: validBidRequest.sizes,
    supplyTypes: mediaTypes,
    adUnitId: params.adUnitId,
    adUnitCode: validBidRequest.adUnitCode,
    geom: geom(validBidRequest.adUnitCode),
    placement: params.placement,
    requestCount: validBidRequest.bidderRequestsCount || 1, // FIXME : in unit test the parameter bidderRequestsCount is undefinedt
  };

  if (hasVideoMediaType(validBidRequest)) {
    bidRequest.videoParams = getVideoParams(validBidRequest);
  }

  return bidRequest;
}

/**
 * return video param (global or overrided per bidder)
 */
function getVideoParams(validBidRequest) {
  const videoParams = validBidRequest.mediaTypes.video || {};
  if (videoParams.playerSize) {
    videoParams.w = videoParams.playerSize[0][0];
    videoParams.h = videoParams.playerSize[0][1];
  }

  return videoParams;
}

function buildBidResponse(seedtagBid) {
  const mediaType = mapMediaType(seedtagBid.mediaType);
  const bid = {
    requestId: seedtagBid.bidId,
    cpm: seedtagBid.price,
    width: seedtagBid.width,
    height: seedtagBid.height,
    creativeId: seedtagBid.creativeId,
    currency: seedtagBid.currency,
    netRevenue: true,
    mediaType: mediaType,
    ttl: seedtagBid.ttl,
    nurl: seedtagBid.nurl,
    meta: {
      advertiserDomains:
        seedtagBid && seedtagBid.adomain && seedtagBid.adomain.length > 0
          ? seedtagBid.adomain
          : [],
    },
  };

  if (mediaType === VIDEO) {
    bid.vastXml = seedtagBid.content;
  } else {
    bid.ad = seedtagBid.content;
  }
  return bid;
}

/**
 *
 * @returns Measure time to first byte implementation
 * @see https://web.dev/ttfb/
 *      https://developer.mozilla.org/en-US/docs/Web/API/Navigation_timing_API
 */
function ttfb() {
  const ttfb = (() => {
    // Timing API V2
    try {
      const entry = performance.getEntriesByType('navigation')[0];
      return Math.round(entry.responseStart - entry.startTime);
    } catch (e) {
      // Timing API V1
      try {
        const entry = performance.timing;
        return Math.round(entry.responseStart - entry.fetchStart);
      } catch (e) {
        // Timing API not available
        return 0;
      }
    }
  })();

  // prevent negative or excessive value
  // @see https://github.com/googleChrome/web-vitals/issues/162
  //      https://github.com/googleChrome/web-vitals/issues/137
  return ttfb >= 0 && ttfb <= performance.now() ? ttfb : 0;
}

function geom(adunitCode) {
  const slot = document.getElementById(adunitCode);
  if (slot) {
    const scrollY = window.scrollY;
    const { top, left, width, height } = slot.getBoundingClientRect();
    const viewport = {
      width: window.innerWidth,
      height: window.innerHeight,
    };

    return {
      scrollY,
      top,
      left,
      width,
      height,
      viewport,
    };
  }
}

export function getTimeoutUrl(data) {
  let queryParams = '';
  if (
    isArray(data) &&
    data[0] &&
    isArray(data[0].params) &&
    data[0].params[0]
  ) {
    const params = data[0].params[0];
    const timeout = data[0].timeout;

    queryParams =
      '?publisherToken=' +
      params.publisherId +
      '&adUnitId=' +
      params.adUnitId +
      '&timeout=' +
      timeout;
  }
  return SEEDTAG_SSP_ONTIMEOUT_ENDPOINT + queryParams;
}

export const spec = {
  code: BIDDER_CODE,
  gvlid: GVLID,
  aliases: [SEEDTAG_ALIAS],
  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(bid) {
    return hasVideoMediaType(bid)
      ? hasMandatoryVideoParams(bid)
      : hasMandatoryDisplayParams(bid);
  },

  /**
   * Make a server request from the list of BidRequests.
   *
   * @param {validBidRequests[]} - an array of bids
   * @return ServerRequest Info describing the request to the server.
   */
  buildRequests(validBidRequests, bidderRequest) {
    const payload = {
      url: bidderRequest.refererInfo.page,
      publisherToken: validBidRequests[0].params.publisherId,
      cmp: !!bidderRequest.gdprConsent,
      timeout: bidderRequest.timeout,
      version: '$prebid.version$',
      connectionType: getConnectionType(),
      auctionStart: bidderRequest.auctionStart || Date.now(),
      ttfb: ttfb(),
      bidRequests: _map(validBidRequests, buildBidRequest),
      user: { topics: [], eids: [] }
    };

    if (payload.cmp) {
      const gdprApplies = bidderRequest.gdprConsent.gdprApplies;
      if (gdprApplies !== undefined) payload['ga'] = gdprApplies;
      payload['cd'] = bidderRequest.gdprConsent.consentString;
    }
    if (bidderRequest.uspConsent) {
      payload['uspConsent'] = bidderRequest.uspConsent;
    }

    if (validBidRequests[0].schain) {
      payload.schain = validBidRequests[0].schain;
    }

    let coppa = config.getConfig('coppa');
    if (coppa) {
      payload.coppa = coppa;
    }

    if (bidderRequest.gppConsent) {
      payload.gppConsent = {
        gppString: bidderRequest.gppConsent.gppString,
        applicableSections: bidderRequest.gppConsent.applicableSections
      }
    } else if (bidderRequest.ortb2?.regs?.gpp) {
      payload.gppConsent = {
        gppString: bidderRequest.ortb2.regs.gpp,
        applicableSections: bidderRequest.ortb2.regs.gpp_sid
      }
    }

    if (bidderRequest.ortb2?.user?.data) {
      payload.user.topics = bidderRequest.ortb2.user.data
    }
    if (validBidRequests[0] && validBidRequests[0].userIdAsEids) {
      payload.user.eids = validBidRequests[0].userIdAsEids
    }

    if (bidderRequest.ortb2?.bcat) {
      payload.bcat = bidderRequest.ortb2?.bcat
    }

    if (bidderRequest.ortb2?.badv) {
      payload.badv = bidderRequest.ortb2?.badv
    }

    if (bidderRequest.ortb2?.device?.sua) {
      payload.sua = bidderRequest.ortb2.device.sua
    }

    const payloadString = JSON.stringify(payload);

    return {
      method: 'POST',
      url: SEEDTAG_SSP_ENDPOINT,
      data: payloadString,
    };
  },

  /**
   * Unpack the response from the server into a list of bids.
   *
   * @param {ServerResponse} serverResponse A successful response from the server.
   * @return {Bid[]} An array of bids which were nested inside the server.
   */
  interpretResponse: function (serverResponse) {
    const serverBody = serverResponse.body;
    if (serverBody && serverBody.bids && isArray(serverBody.bids)) {
      return _map(serverBody.bids, function (bid) {
        return buildBidResponse(bid);
      });
    } else {
      return [];
    }
  },

  /**
   * 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) {
    const serverResponse = serverResponses[0];
    if (syncOptions.iframeEnabled && serverResponse) {
      const cookieSyncUrl = serverResponse.body.cookieSync;
      return cookieSyncUrl ? [{ type: 'iframe', url: cookieSyncUrl }] : [];
    } else {
      return [];
    }
  },

  /**
   * Register bidder specific code, which will execute if bidder timed out after an auction
   * @param {data} Containing timeout specific data
   */
  onTimeout(data) {
    const url = getTimeoutUrl(data);
    triggerPixel(url);
  },

  /**
   * Function to call when the adapter wins the auction
   * @param {bid} Bid information received from the server
   */
  onBidWon: function (bid) {
    if (bid && bid.nurl) {
      triggerPixel(bid.nurl);
    }
  },
};
registerBidder(spec);