prebid/Prebid.js

View on GitHub
modules/yandexBidAdapter.js

Summary

Maintainability
D
2 days
Test Coverage
import { registerBidder } from '../src/adapters/bidderFactory.js';
import { config } from '../src/config.js';
import { BANNER, NATIVE } from '../src/mediaTypes.js';
import { convertOrtbRequestToProprietaryNative } from '../src/native.js';
import { _each, _map, deepAccess, deepSetValue, formatQS, triggerPixel } from '../src/utils.js';

/**
 * @typedef {import('../src/adapters/bidderFactory.js').Bid} Bid
 * @typedef {import('../src/adapters/bidderFactory.js').BidRequest} BidRequest
 * @typedef {import('../src/adapters/bidderFactory.js').BidderSpec} BidderSpec
 * @typedef {import('../src/adapters/bidderFactory.js').ServerRequest} ServerRequest
 * @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/auction.js').BidderRequest} BidderRequest
 * @typedef {import('../src/mediaTypes.js').MediaType} MediaType
 * @typedef {import('../src/utils.js').MediaTypes} MediaTypes
 * @typedef {import('../modules/priceFloors.js').getFloor} GetFloor
 */

/**
 * @typedef {Object} CustomServerRequestFields
 * @property {BidRequest} bidRequest
 */

/**
 * @typedef {ServerRequest & CustomServerRequestFields} YandexServerRequest
 */

/**
 * Yandex bidder-specific params which the publisher used in their bid request.
 *
 * @typedef {Object} YandexBidRequestParams
 * @property {string} placementId Possible formats: `R-I-123456-2`, `R-123456-1`, `123456-789`.
 * @property {number} [pageId] Deprecated. Please use `placementId` instead.
 * @property {number} [impId] Deprecated. Please use `placementId` instead.
 */

/**
 * @typedef {Object} AdditionalBidRequestFields
 * @property {GetFloor} [getFloor]
 * @property {MediaTypes} [mediaTypes]
 */

/**
 * @typedef {BidRequest & AdditionalBidRequestFields} ExtendedBidRequest
 */

const BIDDER_CODE = 'yandex';
const BIDDER_URL = 'https://bs.yandex.ru/prebid';
const DEFAULT_TTL = 180;
const DEFAULT_CURRENCY = 'EUR';
/**
 * @type {MediaType[]}
 */
const SUPPORTED_MEDIA_TYPES = [BANNER, NATIVE];
const SSP_ID = 10500;

const IMAGE_ASSET_TYPES = {
  ICON: 1,
  IMAGE: 3,
};
const DATA_ASSET_TYPES = {
  TITLE: 0,
  SPONSORED: 1,
  DESC: 2,
  RATING: 3,
  LIKES: 4,
  ADDRESS: 9,
  DESC2: 10,
  DISPLAY_URL: 11,
  CTA_TEXT: 12,
  E_504: 504,
};
export const NATIVE_ASSETS = {
  title: [1, DATA_ASSET_TYPES.TITLE],
  body: [2, DATA_ASSET_TYPES.DESC],
  body2: [3, DATA_ASSET_TYPES.DESC2],
  sponsoredBy: [4, DATA_ASSET_TYPES.SPONSORED],
  icon: [5, IMAGE_ASSET_TYPES.ICON],
  image: [6, IMAGE_ASSET_TYPES.IMAGE],
  displayUrl: [7, DATA_ASSET_TYPES.DISPLAY_URL],
  cta: [8, DATA_ASSET_TYPES.CTA_TEXT],
  rating: [9, DATA_ASSET_TYPES.RATING],
  likes: [10, DATA_ASSET_TYPES.LIKES],
}
const NATIVE_ASSETS_IDS = {};
_each(NATIVE_ASSETS, (asset, key) => { NATIVE_ASSETS_IDS[asset[0]] = key });

/** @type {BidderSpec} */
export const spec = {
  code: BIDDER_CODE,
  aliases: ['ya'], // short code
  supportedMediaTypes: SUPPORTED_MEDIA_TYPES,

  /**
   * Determines whether or not the given bid request is valid.
   *
   * @param {BidRequest} bid The bid request to validate.
   * @returns {boolean} True if this is a valid bid, and false otherwise.
   */
  isBidRequestValid: function(bid) {
    const { params } = bid;
    if (!params) {
      return false;
    }
    const { pageId, impId } = extractPlacementIds(params);
    if (!(pageId && impId)) {
      return false;
    }
    return true;
  },

  /**
   * Make a server request from the list of BidRequests.
   *
   * @param {ExtendedBidRequest[]} validBidRequests An array of bids.
   * @param {BidderRequest} bidderRequest Bidder request object.
   * @returns {YandexServerRequest[]} Objects describing the requests to the server.
   */
  buildRequests: function(validBidRequests, bidderRequest) {
    validBidRequests = convertOrtbRequestToProprietaryNative(validBidRequests);

    const ortb2 = bidderRequest.ortb2;

    let timeout = null;
    if (bidderRequest) {
      timeout = bidderRequest.timeout;
    }

    const adServerCurrency = config.getConfig('currency.adServerCurrency');

    return validBidRequests.map((bidRequest) => {
      const { params } = bidRequest;
      const { targetRef, withCredentials = true, cur } = params;

      const { pageId, impId } = extractPlacementIds(params);

      const queryParams = {
        'imp-id': impId,
        'target-ref': targetRef || ortb2?.site?.domain,
        'ssp-id': SSP_ID,
      };

      const gdprApplies = Boolean(deepAccess(bidderRequest, 'gdprConsent.gdprApplies'));
      if (gdprApplies) {
        const consentString = deepAccess(bidderRequest, 'gdprConsent.consentString');
        queryParams['gdpr'] = 1;
        queryParams['tcf-consent'] = consentString;
      }

      const imp = {
        id: impId,
        banner: mapBanner(bidRequest),
        native: mapNative(bidRequest),
      };

      const bidfloor = getBidfloor(bidRequest);
      if (bidfloor) {
        imp.bidfloor = bidfloor.floor;
        imp.bidfloorcur = bidfloor.currency;
      }

      const currency = cur || adServerCurrency;
      if (currency) {
        queryParams['ssp-cur'] = currency;
      }

      const data = {
        id: bidRequest.bidId,
        imp: [imp],
        site: ortb2?.site,
        tmax: timeout,
        user: ortb2?.user,
        device: ortb2?.device,
      };

      const eids = deepAccess(bidRequest, 'userIdAsEids');
      if (eids && eids.length) {
        deepSetValue(data, 'user.ext.eids', eids);
      }

      const queryParamsString = formatQS(queryParams);
      return {
        method: 'POST',
        url: BIDDER_URL + `/${pageId}?${queryParamsString}`,
        data,
        options: {
          withCredentials,
        },
        bidRequest,
      };
    });
  },

  interpretResponse: interpretResponse,

  /**
   * Register bidder specific code, which will execute if a bid from this bidder won the auction.
   * @param {Bid} bid The bid that won the auction.
   */
  onBidWon: function(bid) {
    const nurl = addRTT(bid['nurl'], bid.timeToRespond);

    if (!nurl) {
      return;
    }

    triggerPixel(nurl);
  }
}

/**
 * @param {YandexBidRequestParams} bidRequestParams
 */
function extractPlacementIds(bidRequestParams) {
  const { placementId } = bidRequestParams;
  const result = { pageId: null, impId: null };

  let pageId, impId;
  if (placementId) {
    /*
     * Possible formats
     * R-I-123456-2
     * R-123456-1
     * 123456-789
     */
    const num = placementId.lastIndexOf('-');
    if (num === -1) {
      return result;
    }
    const num2 = placementId.lastIndexOf('-', num - 1);
    pageId = placementId.slice(num2 + 1, num);
    impId = placementId.slice(num + 1);
  } else {
    pageId = bidRequestParams.pageId;
    impId = bidRequestParams.impId;
  }

  if (!parseInt(pageId, 10) || !parseInt(impId, 10)) {
    return result;
  }

  result.pageId = pageId;
  result.impId = impId;

  return result;
}

/**
 * @param {ExtendedBidRequest} bidRequest
 */
function getBidfloor(bidRequest) {
  const floors = [];

  if (typeof bidRequest.getFloor === 'function') {
    SUPPORTED_MEDIA_TYPES.forEach(type => {
      if (bidRequest.hasOwnProperty(type)) {
        const floorInfo = bidRequest.getFloor({
          currency: DEFAULT_CURRENCY,
          mediaType: type,
          size: bidRequest.sizes || '*'
        })
        floors.push(floorInfo);
      }
    });
  }

  return floors.sort((a, b) => b.floor - a.floor)[0];
}

/**
 * @param {ExtendedBidRequest} bidRequest
 */
function mapBanner(bidRequest) {
  if (deepAccess(bidRequest, 'mediaTypes.banner')) {
    const sizes = bidRequest.sizes || bidRequest.mediaTypes.banner.sizes;
    const format = sizes.map((size) => ({
      w: size[0],
      h: size[1],
    }));
    const { w, h } = format[0];

    return {
      format,
      w,
      h,
    }
  }
}

/**
 * @param {ExtendedBidRequest} bidRequest
 */
function mapNative(bidRequest) {
  const adUnitNativeAssets = deepAccess(bidRequest, 'mediaTypes.native');
  if (adUnitNativeAssets) {
    const assets = [];

    Object.keys(adUnitNativeAssets).forEach((assetCode) => {
      if (NATIVE_ASSETS.hasOwnProperty(assetCode)) {
        const nativeAsset = NATIVE_ASSETS[assetCode];
        const adUnitAssetParams = adUnitNativeAssets[assetCode];
        const asset = mapAsset(assetCode, adUnitAssetParams, nativeAsset);
        assets.push(asset);
      }
    });

    return {
      ver: 1.1,
      request: JSON.stringify({
        ver: 1.1,
        assets
      }),
    };
  }
}

function mapAsset(assetCode, adUnitAssetParams, nativeAsset) {
  const [nativeAssetId, nativeAssetType] = nativeAsset;
  const asset = {
    id: nativeAssetId,
  };

  if (adUnitAssetParams.required) {
    asset.required = 1;
  }

  if (assetCode === 'title') {
    asset.title = {
      len: adUnitAssetParams.len || 25,
    };
  } else if (assetCode === 'image' || assetCode === 'icon') {
    asset.img = mapImageAsset(adUnitAssetParams, nativeAssetType);
  } else {
    asset.data = {
      type: nativeAssetType,
      len: adUnitAssetParams.len,
    };
  }

  return asset;
}

function mapImageAsset(adUnitImageAssetParams, nativeAssetType) {
  const img = {
    type: nativeAssetType,
  };

  if (adUnitImageAssetParams.aspect_ratios) {
    const ratio = adUnitImageAssetParams.aspect_ratios[0];
    const minWidth = ratio.min_width || 100;

    img.wmin = minWidth;
    img.hmin = (minWidth / ratio.ratio_width * ratio.ratio_height);
  }

  if (adUnitImageAssetParams.sizes) {
    const size = Array.isArray(adUnitImageAssetParams.sizes[0]) ? adUnitImageAssetParams.sizes[0] : adUnitImageAssetParams.sizes;
    img.w = size[0];
    img.h = size[1];
  }

  return img;
}

/**
 * Unpack the response from the server into a list of bids.
 *
 * @param {ServerResponse} serverResponse A successful response from the server.
 * @param {YandexServerRequest} yandexServerRequest
 * @return {Bid[]} An array of bids which were nested inside the server.
 */
function interpretResponse(serverResponse, { bidRequest }) {
  let response = serverResponse.body;
  if (!response.seatbid) {
    return [];
  }
  const { seatbid, cur } = serverResponse.body;
  const bidsReceived = seatbid
    .map(seatbid => seatbid.bid)
    .reduce((a, b) => a.concat(b), []);

  const currency = cur || DEFAULT_CURRENCY;

  return bidsReceived.map(bidReceived => {
    const price = bidReceived.price;
    /** @type {Bid} */
    let prBid = {
      requestId: bidRequest.bidId,
      cpm: price,
      currency: currency,
      width: bidReceived.w,
      height: bidReceived.h,
      creativeId: bidReceived.adid,
      nurl: replaceAuctionPrice(bidReceived.nurl, price, currency),

      netRevenue: true,
      ttl: DEFAULT_TTL,

      meta: {
        advertiserDomains: bidReceived.adomain && bidReceived.adomain.length > 0 ? bidReceived.adomain : [],
      }
    };

    if (bidReceived.adm.indexOf('{') === 0) {
      prBid.mediaType = NATIVE;
      prBid.native = interpretNativeAd(bidReceived, price, currency);
    } else {
      prBid.mediaType = BANNER;
      prBid.ad = bidReceived.adm;
    }

    return prBid;
  });
}

function interpretNativeAd(bidReceived, price, currency) {
  try {
    const { adm } = bidReceived;
    const { native } = JSON.parse(adm);

    const result = {
      clickUrl: native.link.url,
    };

    native.assets.forEach(asset => {
      const assetCode = NATIVE_ASSETS_IDS[asset.id];
      if (!assetCode) {
        return;
      }
      if (assetCode === 'image' || assetCode === 'icon') {
        result[assetCode] = {
          url: asset.img.url,
          width: asset.img.w,
          height: asset.img.h
        };
      } else if (assetCode === 'title') {
        result[assetCode] = asset.title.text;
      } else {
        result[assetCode] = asset.data.value;
      }
    });

    result.impressionTrackers = _map(native.imptrackers, (tracker) =>
      replaceAuctionPrice(tracker, price, currency)
    );

    return result;
  } catch (e) {}
}

function replaceAuctionPrice(url, price, currency) {
  if (!url) return;

  return url
    .replace(/\${AUCTION_PRICE}/, price)
    .replace(/\${AUCTION_CURRENCY}/, currency);
}

function addRTT(url, rtt) {
  if (!url) return;

  if (url.indexOf(`\${RTT}`) > -1) {
    return url.replace(/\${RTT}/, rtt ?? -1);
  }

  const urlObj = new URL(url);

  if (Number.isInteger(rtt)) {
    urlObj.searchParams.set('rtt', rtt);
  } else {
    urlObj.searchParams.delete('rtt');
  }

  url = urlObj.toString();

  return url;
}

registerBidder(spec);