prebid/Prebid.js

View on GitHub
modules/yieldlabBidAdapter.js

Summary

Maintainability
F
3 days
Test Coverage
import { _each, deepAccess, isArray, isEmptyStr, isFn, isPlainObject, timestamp } from '../src/utils.js';
import { registerBidder } from '../src/adapters/bidderFactory.js';
import { find } from '../src/polyfill.js';
import { BANNER, NATIVE, VIDEO } from '../src/mediaTypes.js';
import { Renderer } from '../src/Renderer.js';
import { convertOrtbRequestToProprietaryNative } from '../src/native.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 ENDPOINT = 'https://ad.yieldlab.net';
const BIDDER_CODE = 'yieldlab';
const BID_RESPONSE_TTL_SEC = 300;
const CURRENCY_CODE = 'EUR';
const OUTSTREAMPLAYER_URL = 'https://ad.adition.com/dynamic.ad?a=o193092&ma_loadEvent=ma-start-event';
const GVLID = 70;
const DIMENSION_SIGN = 'x';
const IMG_TYPE_ICON = 1;
const IMG_TYPE_MAIN = 3;

export const spec = {
  code: BIDDER_CODE,
  gvlid: GVLID,
  supportedMediaTypes: [VIDEO, BANNER, NATIVE],

  /**
   * @param {object} bid
   * @returns {boolean}
   */
  isBidRequestValid(bid) {
    return !!(bid && bid.params && bid.params.adslotId && bid.params.supplyId);
  },

  /**
   * This method should build correct URL
   * @param {BidRequest[]} validBidRequests
   * @param [bidderRequest]
   * @returns {ServerRequest|ServerRequest[]}
   */
  buildRequests(validBidRequests, bidderRequest) {
    // convert Native ORTB definition to old-style prebid native definition
    validBidRequests = convertOrtbRequestToProprietaryNative(validBidRequests);

    const adslotIds = [];
    const adslotSizes = [];
    const adslotFloors = [];
    const timestamp = Date.now();
    const query = {
      ts: timestamp,
      json: true,
    };

    _each(validBidRequests, function (bid) {
      adslotIds.push(bid.params.adslotId);
      const sizes = extractSizes(bid);
      if (sizes.length > 0) {
        adslotSizes.push(bid.params.adslotId + ':' + sizes.join('|'));
      }
      if (bid.params.extId) {
        query.id = bid.params.extId;
      }
      if (bid.params.targeting) {
        query.t = createTargetingString(bid.params.targeting);
      }
      if (bid.userIdAsEids && Array.isArray(bid.userIdAsEids)) {
        query.ids = createUserIdString(bid.userIdAsEids);
        query.atypes = createUserIdAtypesString(bid.userIdAsEids);
      }
      if (bid.params.customParams && isPlainObject(bid.params.customParams)) {
        for (const prop in bid.params.customParams) {
          query[prop] = bid.params.customParams[prop];
        }
      }
      if (bid.schain && isPlainObject(bid.schain) && Array.isArray(bid.schain.nodes)) {
        query.schain = createSchainString(bid.schain);
      }

      const iabContent = getContentObject(bid);
      if (iabContent) {
        query.iab_content = createIabContentString(iabContent);
      }
      const floor = getBidFloor(bid, sizes);
      if (floor) {
        adslotFloors.push(bid.params.adslotId + ':' + floor);
      }
    });

    if (bidderRequest) {
      if (bidderRequest.refererInfo && bidderRequest.refererInfo.page) {
        // TODO: is 'page' the right value here?
        query.pubref = bidderRequest.refererInfo.page;
      }

      if (bidderRequest.gdprConsent) {
        query.gdpr = (typeof bidderRequest.gdprConsent.gdprApplies === 'boolean') ? bidderRequest.gdprConsent.gdprApplies : true;
        if (query.gdpr) {
          query.consent = bidderRequest.gdprConsent.consentString;
        }
      }

      if (bidderRequest.ortb2?.regs?.ext?.dsa !== undefined) {
        const dsa = bidderRequest.ortb2.regs.ext.dsa;

        assignIfNotUndefined(query, 'dsarequired', dsa.dsarequired);
        assignIfNotUndefined(query, 'dsapubrender', dsa.pubrender);
        assignIfNotUndefined(query, 'dsadatatopub', dsa.datatopub);

        if (Array.isArray(dsa.transparency)) {
          const filteredTransparencies = dsa.transparency.filter(({ domain, dsaparams }) => {
            return domain && !domain.includes('~') && Array.isArray(dsaparams) && dsaparams.length > 0 && dsaparams.every(param => typeof param === 'number');
          });

          if (filteredTransparencies.length === 1) {
            const { domain, dsaparams } = filteredTransparencies[0];
            assignIfNotUndefined(query, 'dsadomain', domain);
            assignIfNotUndefined(query, 'dsaparams', dsaparams.join(','));
          } else if (filteredTransparencies.length > 1) {
            const dsatransparency = filteredTransparencies.map(({ domain, dsaparams }) =>
              `${domain}~${dsaparams.join('_')}`
            ).join('~~');
            if (dsatransparency) {
              query.dsatransparency = dsatransparency;
            }
          }
        }
      }

      const topics = getGoogleTopics(bidderRequest);
      if (topics) {
        assignIfNotUndefined(query, 'segtax', topics.segtax);
        assignIfNotUndefined(query, 'segclass', topics.segclass);
        assignIfNotUndefined(query, 'segments', topics.segments);
      }
    }

    const adslots = adslotIds.join(',');
    if (adslotSizes.length > 0) {
      query.sizes = adslotSizes.join(',');
    }

    if (adslotFloors.length > 0) {
      query.floor = adslotFloors.join(',');
    }

    const queryString = createQueryString(query);

    return {
      method: 'GET',
      url: `${ENDPOINT}/yp/${adslots}?${queryString}`,
      validBidRequests: validBidRequests,
      queryParams: query,
    };
  },

  /**
   * Map ad values and pricing and stuff
   * @param {ServerResponse} serverResponse
   * @param {BidRequest} originalBidRequest
   * @returns {Bid[]}
   */
  interpretResponse(serverResponse, originalBidRequest) {
    const bidResponses = [];
    const timestamp = Date.now();
    const reqParams = originalBidRequest.queryParams;

    originalBidRequest.validBidRequests.forEach(function (bidRequest) {
      if (!serverResponse.body) {
        return;
      }

      const matchedBid = find(serverResponse.body, function (bidResponse) {
        return bidRequest.params.adslotId == bidResponse.id;
      });

      if (matchedBid) {
        const adUnitSize = bidRequest.sizes.length === 2 && !isArray(bidRequest.sizes[0]) ? bidRequest.sizes : bidRequest.sizes[0];
        const adSize = bidRequest.params.adSize !== undefined ? parseSize(bidRequest.params.adSize) : (matchedBid.adsize !== undefined) ? parseSize(matchedBid.adsize) : adUnitSize;
        const extId = bidRequest.params.extId !== undefined ? '&id=' + bidRequest.params.extId : '';
        const adType = matchedBid.adtype !== undefined ? matchedBid.adtype : '';
        const gdprApplies = reqParams.gdpr ? '&gdpr=' + reqParams.gdpr : '';
        const gdprConsent = reqParams.consent ? '&consent=' + reqParams.consent : '';
        const pvId = matchedBid.pvid !== undefined ? '&pvid=' + matchedBid.pvid : '';
        const iabContent = reqParams.iab_content ? '&iab_content=' + reqParams.iab_content : '';

        const bidResponse = {
          requestId: bidRequest.bidId,
          cpm: matchedBid.price / 100,
          width: adSize[0],
          height: adSize[1],
          creativeId: '' + matchedBid.id,
          dealId: (matchedBid['c.dealid']) ? matchedBid['c.dealid'] : matchedBid.pid,
          currency: CURRENCY_CODE,
          netRevenue: false,
          ttl: BID_RESPONSE_TTL_SEC,
          referrer: '',
          ad: `<script src="${ENDPOINT}/d/${matchedBid.id}/${bidRequest.params.supplyId}/?ts=${timestamp}${extId}${gdprApplies}${gdprConsent}${pvId}${iabContent}"></script>`,
          meta: {
            advertiserDomains: (matchedBid.advertiser) ? matchedBid.advertiser : 'n/a',
          },
        };

        const dsa = getDigitalServicesActObjectFromMatchedBid(matchedBid)
        if (dsa !== undefined) {
          bidResponse.meta = { ...bidResponse.meta, dsa: dsa };
        }

        if (isVideo(bidRequest, adType)) {
          const playersize = getPlayerSize(bidRequest);
          if (playersize) {
            bidResponse.width = playersize[0];
            bidResponse.height = playersize[1];
          }
          bidResponse.mediaType = VIDEO;
          bidResponse.vastUrl = `${ENDPOINT}/d/${matchedBid.id}/${bidRequest.params.supplyId}/?ts=${timestamp}${extId}${gdprApplies}${gdprConsent}${pvId}${iabContent}`;
          if (isOutstream(bidRequest)) {
            const renderer = Renderer.install({
              id: bidRequest.bidId,
              url: OUTSTREAMPLAYER_URL,
              loaded: false,
            });
            renderer.setRender(outstreamRender);
            bidResponse.renderer = renderer;
          }
        }

        if (isNative(bidRequest, adType)) {
          const { native } = matchedBid;
          const { assets } = native;
          bidResponse.adUrl = `${ENDPOINT}/d/${matchedBid.id}/${bidRequest.params.supplyId}/?ts=${timestamp}${extId}${gdprApplies}${gdprConsent}${pvId}`;
          bidResponse.mediaType = NATIVE;
          const nativeIconAssetObj = find(assets, isImageAssetOfType(IMG_TYPE_ICON));
          const nativeImageAssetObj = find(assets, isImageAssetOfType(IMG_TYPE_MAIN));
          const nativeImageAsset = nativeImageAssetObj ? nativeImageAssetObj.img : { url: '', w: 0, h: 0 };
          const nativeTitleAsset = find(assets, asset => hasValidProperty(asset, 'title'));
          const nativeBodyAsset = find(assets, asset => hasValidProperty(asset, 'data'));
          bidResponse.native = {
            title: nativeTitleAsset ? nativeTitleAsset.title.text : '',
            body: nativeBodyAsset ? nativeBodyAsset.data.value : '',
            ...nativeIconAssetObj?.img && {
              icon: {
                url: nativeIconAssetObj.img.url,
                width: nativeIconAssetObj.img.w,
                height: nativeIconAssetObj.img.h,
              },
            },
            image: {
              url: nativeImageAsset.url,
              width: nativeImageAsset.w,
              height: nativeImageAsset.h,
            },
            clickUrl: native.link.url,
            impressionTrackers: native.imptrackers,
            assets: assets,
          };
        }

        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 {Object} gdprConsent Is the GDPR Consent object wrapping gdprApplies {boolean} and consentString {string} attributes.
   * @param {string} uspConsent Is the US Privacy Consent string.
   * @return {UserSync[]} The user syncs which should be dropped.
   */
  getUserSyncs(syncOptions, serverResponses, gdprConsent, uspConsent) {
    const syncs = [];

    if (syncOptions.iframeEnabled) {
      const params = [];
      params.push(`ts=${timestamp()}`);
      params.push(`type=h`);
      if (gdprConsent && (typeof gdprConsent.gdprApplies === 'boolean')) {
        params.push(`gdpr=${Number(gdprConsent.gdprApplies)}`);
      }
      if (gdprConsent && (typeof gdprConsent.consentString === 'string')) {
        params.push(`gdpr_consent=${gdprConsent.consentString}`);
      }
      syncs.push({
        type: 'iframe',
        url: `${ENDPOINT}/d/6846326/766/2x2?${params.join('&')}`,
      });
    }

    return syncs;
  },
};

/**
 * Is this a video format?
 * @param {Object} format
 * @param {String} adtype
 * @returns {Boolean}
 */
function isVideo(format, adtype) {
  return deepAccess(format, 'mediaTypes.video') && adtype.toLowerCase() === 'video';
}

/**
 * Is this a native format?
 * @param {Object} format
 * @param {String} adtype
 * @returns {Boolean}
 */
function isNative(format, adtype) {
  return deepAccess(format, 'mediaTypes.native') && adtype.toLowerCase() === 'native';
}

/**
 * Is this an outstream context?
 * @param {Object} format
 * @returns {Boolean}
 */
function isOutstream(format) {
  const context = deepAccess(format, 'mediaTypes.video.context');
  return (context === 'outstream');
}

/**
 * Gets optional player size
 * @param {Object} format
 * @returns {Array}
 */
function getPlayerSize(format) {
  const playerSize = deepAccess(format, 'mediaTypes.video.playerSize');
  return (playerSize && isArray(playerSize[0])) ? playerSize[0] : playerSize;
}

/**
 * Expands a 'WxH' string to a 2-element [W, H] array
 * @param {String} size
 * @returns {Array}
 */
function parseSize(size) {
  return size.split(DIMENSION_SIGN).map(Number);
}

/**
 * Creates a string out of an array of eids with source and uid
 * @param {Array.<{source: String, uids: Array.<{id: String, atype: Number, ext: Object}>}>} eids
 * @returns {String}
 */
function createUserIdString(eids) {
  const str = [];
  for (let i = 0; i < eids.length; i++) {
    str.push(eids[i].source + ':' + eids[i].uids[0].id);
  }
  return str.join(',');
}

/**
 * Creates a string from an array of eids with ID provider and atype if atype exists
 * @param {Array.<{source: String, uids: Array.<{id: String, atype: Number, ext: Object}>}>} eids
 * @returns {String} idprovider:atype,idprovider2:atype2,...
 */
function createUserIdAtypesString(eids) {
  const str = [];
  for (let i = 0; i < eids.length; i++) {
    if (eids[i].uids[0].atype) {
      str.push(eids[i].source + ':' + eids[i].uids[0].atype);
    }
  }
  return str.join(',');
}

/**
 * Creates a querystring out of an object with key-values
 * @param {Object} obj
 * @returns {String}
 */
function createQueryString(obj) {
  const str = [];
  for (const p in obj) {
    if (obj.hasOwnProperty(p)) {
      const val = obj[p];
      if (p !== 'schain' && p !== 'iab_content') {
        str.push(encodeURIComponent(p) + '=' + encodeURIComponent(val));
      } else {
        str.push(p + '=' + val);
      }
    }
  }
  return str.join('&');
}

/**
 * Creates an unencoded targeting string out of an object with key-values
 * @param {Object} obj
 * @returns {String}
 */
function createTargetingString(obj) {
  const str = [];
  for (const p in obj) {
    if (obj.hasOwnProperty(p)) {
      const key = p;
      const val = obj[p];
      str.push(key + '=' + val);
    }
  }
  return str.join('&');
}

/**
 * Creates a string out of a schain object
 * @param {Object} schain
 * @returns {String}
 */
function createSchainString(schain) {
  const ver = schain.ver || '';
  const complete = (schain.complete === 1 || schain.complete === 0) ? schain.complete : '';
  const keys = ['asi', 'sid', 'hp', 'rid', 'name', 'domain', 'ext'];
  const nodesString = schain.nodes.reduce((acc, node) => {
    return acc += `!${keys.map(key => node[key] ? encodeURIComponentWithBangIncluded(node[key]) : '').join(',')}`;
  }, '');
  return `${ver},${complete}${nodesString}`;
}

/**
 * Get content object from bid request
 * First get content from bidder params;
 * If not provided in bidder params, get from first party data under 'ortb2.site.content' or 'ortb2.app.content'
 * @param {Object} bid
 * @returns {Object}
 */
function getContentObject(bid) {
  if (bid.params.iabContent && isPlainObject(bid.params.iabContent)) {
    return bid.params.iabContent;
  }

  const globalContent = deepAccess(bid, 'ortb2.site') ? deepAccess(bid, 'ortb2.site.content')
    : deepAccess(bid, 'ortb2.app.content');
  if (globalContent && isPlainObject(globalContent)) {
    return globalContent;
  }
  return undefined;
}

/**
 * Creates a string for iab_content object by
 * 1. flatten the iab content object
 * 2. encoding the values
 * 3. joining array of defined keys ('keyword', 'cat') into one value seperated with '|'
 * 4. encoding the whole string
 * @param {Object} iabContent
 * @returns {String}
 */
function createIabContentString(iabContent) {
  const arrKeys = ['keywords', 'cat'];
  const str = [];
  const transformObjToParam = (obj = {}, extraKey = '') => {
    for (const key in obj) {
      if ((arrKeys.indexOf(key) !== -1 && Array.isArray(obj[key]))) {
        // Array of defined keyword which have to be joined into one value from "key: [value1, value2, value3]" to "key:value1|value2|value3"
        str.push(''.concat(key, ':', obj[key].map(node => encodeURIComponent(node)).join('|')));
      } else if (typeof obj[key] !== 'object') {
        str.push(''.concat(extraKey + key, ':', encodeURIComponent(obj[key])));
      } else {
        // Object has to be further flattened
        transformObjToParam(obj[key], ''.concat(extraKey, key, '.'));
      }
    }
    return str.join(',');
  };
  return encodeURIComponent(transformObjToParam(iabContent));
}

/**
 * Encodes URI Component with exlamation mark included. Needed for schain object.
 * @param {String} str
 * @returns {String}
 */
function encodeURIComponentWithBangIncluded(str) {
  return encodeURIComponent(str).replace(/!/g, '%21');
}

/**
 * Handles an outstream response after the library is loaded
 * @param {Object} bid
 */
function outstreamRender(bid) {
  bid.renderer.push(() => {
    window.ma_width = bid.width;
    window.ma_height = bid.height;
    window.ma_vastUrl = bid.vastUrl;
    window.ma_container = bid.adUnitCode;
    window.document.dispatchEvent(new Event('ma-start-event'));
  });
}

/**
 * Extract sizes for a given bid from either `mediaTypes` or `sizes` directly.
 *
 * @param {Object} bid
 * @returns {string[]}
 */
function extractSizes(bid) {
  const { mediaTypes } = bid; // see https://docs.prebid.org/dev-docs/adunit-reference.html#examples
  const sizes = [];

  if (isPlainObject(mediaTypes)) {
    const { [BANNER]: bannerType } = mediaTypes;

    // only applies for multi size Adslots -> BANNER
    if (bannerType && isArray(bannerType.sizes)) {
      if (isArray(bannerType.sizes[0])) { // multiple sizes given
        sizes.push(bannerType.sizes);
      } else { // just one size provided as array -> wrap to uniformly flatten later
        sizes.push([bannerType.sizes]);
      }
    }
    // The bid top level field `sizes` is deprecated and should not be used anymore. Keeping it for compatibility.
  } else if (isArray(bid.sizes)) {
    if (isArray(bid.sizes[0])) {
      sizes.push(bid.sizes);
    } else {
      sizes.push([bid.sizes]);
    }
  }

  /** @type {Set<string>} */
  const deduplicatedSizeStrings = new Set(sizes.flat().map(([width, height]) => width + DIMENSION_SIGN + height));

  return Array.from(deduplicatedSizeStrings);
}

/**
 * Gets the floor price if the Price Floors Module is enabled for a given auction,
 * which will add the getFloor() function to the bidRequest object.
 *
 * @param {Object} bid
 * @param {string[]} sizes
 * @returns The floor CPM in cents of a matched rule based on the rule selection process (mediaType, size and currency),
 *          using the getFloor() inputs. Multi sizes and unsupported media types will default to '*'
 */
function getBidFloor(bid, sizes) {
  if (!isFn(bid.getFloor)) {
    return undefined;
  }
  const mediaTypes = deepAccess(bid, 'mediaTypes');
  const mediaType = mediaTypes !== undefined ? Object.keys(mediaTypes)[0].toLowerCase() : undefined;
  const floor = bid.getFloor({
    currency: CURRENCY_CODE,
    mediaType: mediaType !== undefined && spec.supportedMediaTypes.includes(mediaType) ? mediaType : '*',
    size: sizes.length !== 1 ? '*' : sizes[0].split(DIMENSION_SIGN),
  });
  if (floor.currency === CURRENCY_CODE) {
    return (floor.floor * 100).toFixed(0);
  }
  return undefined;
}

/**
 * Checks if an object has a property with a given name and the property value is not null or undefined.
 *
 * @param {Object} obj - The object to check.
 * @param {string} propName - The name of the property to check.
 * @returns {boolean} Returns true if the object has a property with the given name and the property value is not null or undefined, otherwise false.
 */
function hasValidProperty(obj, propName) {
  return obj.hasOwnProperty(propName) && obj[propName] != null;
}

/**
 * Returns a filtering function for image assets based on type.
 * @param {number} type - The desired asset type to filter for i.e. IMG_TYPE_ICON = 1, IMG_TYPE_MAIN = 3
 * @returns {function} - A filtering function that accepts an asset and checks if its img.type matches the desired type.
 */
function isImageAssetOfType(type) {
  return asset => asset?.img?.type === type;
}

/**
 * Retrieves the Digital Services Act (DSA) object from a matched bid.
 * Only includes specific attributes (behalf, paid, transparency, adrender) from the DSA object.
 *
 * @param {Object} matchedBid - The server response body to inspect for the DSA information.
 * @returns {Object|undefined} A copy of the DSA object if it exists, or undefined if not.
 */
function getDigitalServicesActObjectFromMatchedBid(matchedBid) {
  if (matchedBid.dsa) {
    const { behalf, paid, transparency, adrender } = matchedBid.dsa;
    return {
      ...(behalf !== undefined && { behalf }),
      ...(paid !== undefined && { paid }),
      ...(transparency !== undefined && { transparency }),
      ...(adrender !== undefined && { adrender })
    };
  }
  return undefined;
}

/**
 * Conditionally assigns a value to a specified key on an object if the value is not undefined.
 *
 * @param {Object} obj - The object to which the value will be assigned.
 * @param {string} key - The key under which the value should be assigned.
 * @param {*} value - The value to be assigned, if it is not undefined.
 */
function assignIfNotUndefined(obj, key, value) {
  if (value !== undefined) {
    obj[key] = value;
  }
}

function getGoogleTopics(bid) {
  const userData = deepAccess(bid, 'ortb2.user.data') || [];
  const validData = userData.filter(dataObj =>
    dataObj.segment && isArray(dataObj.segment) && dataObj.segment.length > 0 &&
      dataObj.segment.every(seg => (seg.id && !isEmptyStr(seg.id) && isFinite(seg.id)))
  )[0];

  if (validData) {
    return {
      segtax: validData.ext?.segtax,
      segclass: validData.ext?.segclass,
      segments: validData.segment.map(seg => Number(seg.id)).join(','),
    };
  }

  return undefined;
}

registerBidder(spec);