From 53b3f06776661d2a7fbd406e7d52ebd1a869787b Mon Sep 17 00:00:00 2001 From: Saveliev Taras Date: Wed, 15 Mar 2023 11:07:33 +0100 Subject: [PATCH] Yandex Bid Adapter: (#9604) * added support media-type native * fixed gdpr parameters * changed endpoint url Co-authored-by: Taras Saveliev --- modules/yandexBidAdapter.js | 330 +++++++++++++++---- modules/yandexBidAdapter.md | 67 +++- test/spec/modules/yandexBidAdapter_spec.js | 360 ++++++++++++++++++--- 3 files changed, 626 insertions(+), 131 deletions(-) diff --git a/modules/yandexBidAdapter.js b/modules/yandexBidAdapter.js index f73b4369869..2870229f1db 100644 --- a/modules/yandexBidAdapter.js +++ b/modules/yandexBidAdapter.js @@ -1,17 +1,50 @@ -import { formatQS, deepAccess, triggerPixel, isArray, isNumber } from '../src/utils.js'; +import { formatQS, deepAccess, triggerPixel, _each, _map } from '../src/utils.js'; import { registerBidder } from '../src/adapters/bidderFactory.js'; -import { BANNER } from '../src/mediaTypes.js' +import { BANNER, NATIVE } from '../src/mediaTypes.js' +import { convertOrtbRequestToProprietaryNative } from '../src/native.js'; const BIDDER_CODE = 'yandex'; -const BIDDER_URL = 'https://bs.yandex.ru/metadsp'; +const BIDDER_URL = 'https://bs.yandex.ru/prebid'; const DEFAULT_TTL = 180; const DEFAULT_CURRENCY = 'EUR'; +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 }); + export const spec = { code: BIDDER_CODE, aliases: ['ya'], // short code - supportedMediaTypes: [ BANNER ], + supportedMediaTypes: SUPPORTED_MEDIA_TYPES, isBidRequestValid: function(bid) { const { params } = bid; @@ -22,13 +55,11 @@ export const spec = { if (!(pageId && impId)) { return false; } - const sizes = bid.mediaTypes?.banner?.sizes; - return isArray(sizes) && isArray(sizes[0]) && isNumber(sizes[0][0]) && isNumber(sizes[0][1]); + return true; }, buildRequests: function(validBidRequests, bidderRequest) { - const gdprApplies = deepAccess(bidderRequest, 'gdprConsent.gdprApplies'); - const consentString = deepAccess(bidderRequest, 'gdprConsent.consentString'); + validBidRequests = convertOrtbRequestToProprietaryNative(validBidRequests); let referrer = ''; let domain = ''; @@ -48,9 +79,6 @@ export const spec = { return validBidRequests.map((bidRequest) => { const { params } = bidRequest; const { targetRef, withCredentials = true } = params; - const sizes = bidRequest.mediaTypes.banner.sizes; - const size = sizes[0]; - const [ w, h ] = size; const { pageId, impId } = extractPlacementIds(params); @@ -59,28 +87,24 @@ export const spec = { 'target-ref': targetRef || domain, 'ssp-id': SSP_ID, }; - if (gdprApplies !== undefined) { + + 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: { - format: sizes, - w, - h, - } + banner: mapBanner(bidRequest), + native: mapNative(bidRequest), }; - if (bidRequest.getFloor) { - const floorInfo = bidRequest.getFloor({ - currency: DEFAULT_CURRENCY, - size - }); - - imp.bidfloor = floorInfo.floor; - imp.bidfloorcur = floorInfo.currency; + const bidfloor = getBidfloor(bidRequest); + if (bidfloor) { + imp.bidfloor = bidfloor.floor; + imp.bidfloorcur = bidfloor.currency; } const queryParamsString = formatQS(queryParams); @@ -91,8 +115,9 @@ export const spec = { id: bidRequest.bidId, imp: [imp], site: { - ref_url: referrer, - page_url: page, + ref: referrer, + page, + domain, }, tmax: timeout, }, @@ -104,58 +129,27 @@ export const spec = { }); }, - interpretResponse: function(serverResponse, {bidRequest}) { - let response = serverResponse.body; - if (!response.seatbid) { - return []; - } - - const { cur, seatbid } = serverResponse.body; - const rtbBids = seatbid - .map(seatbid => seatbid.bid) - .reduce((a, b) => a.concat(b), []); - - return rtbBids.map(rtbBid => { - let prBid = { - requestId: bidRequest.bidId, - cpm: rtbBid.price, - currency: cur || DEFAULT_CURRENCY, - width: rtbBid.w, - height: rtbBid.h, - creativeId: rtbBid.adid, - nurl: rtbBid.nurl, - - netRevenue: true, - ttl: DEFAULT_TTL, - - meta: { - advertiserDomains: rtbBid.adomain && rtbBid.adomain.length > 0 ? rtbBid.adomain : [], - } - }; - - prBid.ad = rtbBid.adm; - - return prBid; - }); - }, + interpretResponse: interpretResponse, onBidWon: function (bid) { let nurl = bid['nurl']; + if (!nurl) { return; } - const cpm = deepAccess(bid, 'adserverTargeting.hb_pb') || ''; - const curr = (bid.hasOwnProperty('originalCurrency') && bid.hasOwnProperty('originalCpm')) - ? bid.originalCurrency - : bid.currency; - - nurl = nurl - .replace(/\${AUCTION_PRICE}/, cpm) - .replace(/\${AUCTION_CURRENCY}/, curr) - ; + let cpm, currency; + if (bid.hasOwnProperty('originalCurrency') && bid.hasOwnProperty('originalCpm')) { + cpm = bid.originalCpm; + currency = bid.originalCurrency; + } else { + cpm = bid.cpm; + currency = bid.currency; + } + cpm = deepAccess(bid, 'adserverTargeting.hb_pb') || cpm; - triggerPixel(nurl); + const pixel = replaceAuctionPrice(nurl, cpm, currency); + triggerPixel(pixel); } } @@ -193,4 +187,198 @@ function extractPlacementIds(bidRequestParams) { return result; } +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]; +} + +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, + } + } +} + +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; +} + +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; + let prBid = { + requestId: bidRequest.bidId, + cpm: price, + currency: currency, + width: bidReceived.w, + height: bidReceived.h, + creativeId: bidReceived.adid, + nurl: bidReceived.nurl, + + 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); +} + registerBidder(spec); diff --git a/modules/yandexBidAdapter.md b/modules/yandexBidAdapter.md index aee51bca249..55a658cc25c 100644 --- a/modules/yandexBidAdapter.md +++ b/modules/yandexBidAdapter.md @@ -20,21 +20,58 @@ Yandex Bidder Adapter for Prebid.js. # Test Parameters -``` -var adUnits = [{ - code: 'banner-1', - mediaTypes: { - banner: { - sizes: [[240, 400], [300, 600]], - } +```javascript +var adUnits = [ + { // banner + code: 'banner-1', + mediaTypes: { + banner: { + sizes: [[240, 400], [300, 600]], + } + }, + bids: [ + { + bidder: 'yandex', + params: { + placementId: '346580-1' + }, + } + ], }, - bids: [{ - { - bidder: 'yandex', - params: { - placementId: '346580-1' + { // native + code: 'banner-2', + mediaTypes: { + native: { + title: { + required: true, + len: 25 + }, + image: { + required: true, + sizes: [300, 250], + }, + icon: { + sizes: [32, 32], + }, + body: { + len: 90 + }, + body2: { + len: 90 + }, + sponsoredBy: { + len: 25, + } }, - } - }] -}]; + }, + bids: [ + { + bidder: 'yandex', + params: { + placementId: '346580-1' + }, + } + ], + }, +]; ``` diff --git a/test/spec/modules/yandexBidAdapter_spec.js b/test/spec/modules/yandexBidAdapter_spec.js index df91100b966..12d413a9c93 100644 --- a/test/spec/modules/yandexBidAdapter_spec.js +++ b/test/spec/modules/yandexBidAdapter_spec.js @@ -1,34 +1,10 @@ import { assert, expect } from 'chai'; -import { spec } from 'modules/yandexBidAdapter.js'; +import { spec, NATIVE_ASSETS } from 'modules/yandexBidAdapter.js'; import { parseUrl } from 'src/utils.js'; -import { BANNER } from '../../../src/mediaTypes'; +import { BANNER, NATIVE } from '../../../src/mediaTypes'; +import {OPENRTB} from '../../../modules/rtbhouseBidAdapter'; describe('Yandex adapter', function () { - function getBidConfig() { - return { - bidder: 'yandex', - params: { - placementId: '123-1', - }, - }; - } - - function getBidRequest() { - return { - ...getBidConfig(), - bidId: 'bidid-1', - adUnitCode: 'adUnit-123', - mediaTypes: { - banner: { - sizes: [ - [300, 250], - [300, 600] - ], - }, - }, - }; - } - describe('isBidRequestValid', function () { it('should return true when required params found', function () { const bid = getBidRequest(); @@ -65,19 +41,17 @@ describe('Yandex adapter', function () { }); describe('buildRequests', function () { - const gdprConsent = { - gdprApplies: 1, - consentString: 'concent-string', - apiVersion: 1, - }; - const bidderRequest = { refererInfo: { domain: 'ya.ru', ref: 'https://ya.ru/', page: 'https://ya.ru/', }, - gdprConsent + gdprConsent: { + gdprApplies: 1, + consentString: 'concent-string', + apiVersion: 1, + }, }; it('creates a valid banner request', function () { @@ -101,7 +75,7 @@ describe('Yandex adapter', function () { const { search: query } = parsedRequestUrl expect(parsedRequestUrl.hostname).to.equal('bs.yandex.ru'); - expect(parsedRequestUrl.pathname).to.equal('/metadsp/123'); + expect(parsedRequestUrl.pathname).to.equal('/prebid/123'); expect(query['imp-id']).to.equal('1'); expect(query['target-ref']).to.equal('ya.ru'); @@ -112,21 +86,197 @@ describe('Yandex adapter', function () { expect(request.data).to.exist; expect(data.site).to.not.equal(null); - expect(data.site.page_url).to.equal('https://ya.ru/'); - expect(data.site.ref_url).to.equal('https://ya.ru/'); + expect(data.site.page).to.equal('https://ya.ru/'); + expect(data.site.ref).to.equal('https://ya.ru/'); + }); + + describe('banner', () => { + it('should create valid banner object', () => { + const bannerRequest = getBidRequest({ + mediaTypes: { + banner: { + sizes: [ + [300, 250], + [300, 600] + ], + }, + } + }); - // expect(data.device).to.not.equal(null); - // expect(data.device.w).to.equal(window.innerWidth); - // expect(data.device.h).to.equal(window.innerHeight); + const requests = spec.buildRequests([bannerRequest], bidderRequest); + expect(requests[0].data.imp).to.have.lengthOf(1); - expect(data.imp).to.have.lengthOf(1); - expect(data.imp[0].banner).to.not.equal(null); - expect(data.imp[0].banner.w).to.equal(300); - expect(data.imp[0].banner.h).to.equal(250); + const imp = requests[0].data.imp[0]; + expect(imp.banner).to.not.equal(null); + expect(imp.banner.w).to.equal(300); + expect(imp.banner.h).to.equal(250); + + expect(imp.banner.format).to.deep.equal([ + { w: 300, h: 250 }, + { w: 300, h: 600 }, + ]); + }); }); + + describe('native', () => { + function buildRequestAndGetNativeParams(extra) { + const bannerRequest = getBidRequest(extra); + const requests = spec.buildRequests([bannerRequest], bidderRequest); + + return JSON.parse(requests[0].data.imp[0].native.request); + } + + it('should extract native params', () => { + const nativeParams = buildRequestAndGetNativeParams({ + mediaTypes: { + native: { + title: { + required: true, + len: 100, + }, + body: { + len: 90 + }, + body2: { + len: 90 + }, + sponsoredBy: { + len: 25, + }, + icon: { + sizes: [32, 32], + }, + image: { + required: true, + sizes: [300, 250], + }, + }, + }, + }); + const sortedAssetsList = nativeParams.assets.sort((a, b) => a.id - b.id); + + expect(sortedAssetsList).to.deep.equal([ + { + id: NATIVE_ASSETS.title[0], + required: 1, + title: { + len: 100, + } + }, + { + id: NATIVE_ASSETS.body[0], + data: { + type: NATIVE_ASSETS.body[1], + len: 90, + }, + }, + { + id: NATIVE_ASSETS.body2[0], + data: { + type: NATIVE_ASSETS.body2[1], + len: 90, + }, + }, + { + id: NATIVE_ASSETS.sponsoredBy[0], + data: { + type: NATIVE_ASSETS.sponsoredBy[1], + len: 25, + }, + }, + { + id: NATIVE_ASSETS.icon[0], + img: { + type: NATIVE_ASSETS.icon[1], + w: 32, + h: 32, + }, + }, + { + id: NATIVE_ASSETS.image[0], + required: 1, + img: { + type: NATIVE_ASSETS.image[1], + w: 300, + h: 250, + }, + }, + ]); + }); + + it('should parse multiple image sizes', () => { + const nativeParams = buildRequestAndGetNativeParams({ + mediaTypes: { + native: { + image: { + sizes: [[300, 250], [100, 100]], + }, + }, + }, + }); + + expect(nativeParams.assets[0]).to.deep.equal({ + id: NATIVE_ASSETS.image[0], + img: { + type: NATIVE_ASSETS.image[1], + w: 300, + h: 250, + }, + }); + }); + + it('should parse aspect ratios with min_width', () => { + const nativeParams = buildRequestAndGetNativeParams({ + mediaTypes: { + native: { + image: { + aspect_ratios: [{ + min_width: 320, + ratio_width: 4, + ratio_height: 3, + }], + }, + }, + }, + }); + + expect(nativeParams.assets[0]).to.deep.equal({ + id: NATIVE_ASSETS.image[0], + img: { + type: NATIVE_ASSETS.image[1], + wmin: 320, + hmin: 240, + }, + }); + }); + + it('should parse aspect ratios without min_width', () => { + const nativeParams = buildRequestAndGetNativeParams({ + mediaTypes: { + native: { + image: { + aspect_ratios: [{ + ratio_width: 4, + ratio_height: 3, + }], + }, + }, + }, + }); + + expect(nativeParams.assets[0]).to.deep.equal({ + id: NATIVE_ASSETS.image[0], + img: { + type: NATIVE_ASSETS.image[1], + wmin: 100, + hmin: 75, + }, + }); + }); + }) }); - describe('response handler', function () { + describe('interpretResponse', function () { const bannerRequest = getBidRequest(); const bannerResponse = { @@ -172,5 +322,125 @@ describe('Yandex adapter', function () { expect(rtbBid.meta.advertiserDomains).to.deep.equal(['example.com']); }); + + describe('native', () => { + function getNativeAdmResponse() { + return { + native: { + link: { + url: 'https://example.com' + }, + imptrackers: [ + 'https://example.com/imptracker' + ], + assets: [ + { + title: { + text: 'title text', + }, + id: NATIVE_ASSETS.title[0], + }, + { + data: { + value: 'body text' + }, + id: NATIVE_ASSETS.body[0], + }, + { + data: { + value: 'sponsoredBy text' + }, + id: NATIVE_ASSETS.sponsoredBy[0], + }, + { + img: { + url: 'https://example.com/image', + w: 200, + h: 150, + }, + id: NATIVE_ASSETS.image[0], + }, + { + img: { + url: 'https://example.com/icon', + h: 32, + w: 32 + }, + id: NATIVE_ASSETS.icon[0], + }, + ] + } + }; + } + + it('handles native responses', function() { + bannerRequest.bidRequest = { + mediaType: NATIVE, + bidId: 'bidid-1', + }; + + const nativeAdmResponce = getNativeAdmResponse(); + const bannerResponse = { + body: { + seatbid: [{ + bid: [ + { + impid: 1, + price: 0.3, + adomain: [ + 'example.com' + ], + adid: 'yabs.123=', + adm: JSON.stringify(nativeAdmResponce), + }, + ], + }], + }, + }; + + const result = spec.interpretResponse(bannerResponse, bannerRequest); + + expect(result).to.have.lengthOf(1); + expect(result[0]).to.exist; + + const bid = result[0]; + expect(bid.meta.advertiserDomains).to.deep.equal(['example.com']); + expect(bid.native).to.deep.equal({ + clickUrl: 'https://example.com', + impressionTrackers: ['https://example.com/imptracker'], + title: 'title text', + body: 'body text', + sponsoredBy: 'sponsoredBy text', + image: { + url: 'https://example.com/image', + width: 200, + height: 150, + }, + icon: { + url: 'https://example.com/icon', + width: 32, + height: 32, + }, + }); + }); + }); }); }); + +function getBidConfig() { + return { + bidder: 'yandex', + params: { + placementId: '123-1', + }, + }; +} + +function getBidRequest(extra = {}) { + return { + ...getBidConfig(), + bidId: 'bidid-1', + adUnitCode: 'adUnit-123', + ...extra, + }; +}