diff --git a/modules/outbrainBidAdapter.js b/modules/outbrainBidAdapter.js new file mode 100644 index 00000000000..402d4515abc --- /dev/null +++ b/modules/outbrainBidAdapter.js @@ -0,0 +1,274 @@ +// jshint esversion: 6, es3: false, node: true +'use strict'; + +import { + registerBidder +} from '../src/adapters/bidderFactory.js'; +import { NATIVE, BANNER } from '../src/mediaTypes.js'; +import * as utils from '../src/utils.js'; +import { ajax } from '../src/ajax.js'; +import { config } from '../src/config.js'; + +const BIDDER_CODE = 'outbrain'; +const GVLID = 164; +const CURRENCY = 'USD'; +const NATIVE_ASSET_IDS = { 0: 'title', 2: 'icon', 3: 'image', 5: 'sponsoredBy', 4: 'body', 1: 'cta' }; +const NATIVE_PARAMS = { + title: { id: 0, name: 'title' }, + icon: { id: 2, type: 1, name: 'img' }, + image: { id: 3, type: 3, name: 'img' }, + sponsoredBy: { id: 5, name: 'data', type: 1 }, + body: { id: 4, name: 'data', type: 2 }, + cta: { id: 1, type: 12, name: 'data' } +}; + +export const spec = { + code: BIDDER_CODE, + gvlid: GVLID, + supportedMediaTypes: [ NATIVE, BANNER ], + isBidRequestValid: (bid) => { + return ( + !!config.getConfig('outbrain.bidderUrl') && + !!utils.deepAccess(bid, 'params.publisher.id') && + !!(bid.nativeParams || bid.sizes) + ); + }, + buildRequests: (validBidRequests, bidderRequest) => { + const page = bidderRequest.refererInfo.referer; + const ua = navigator.userAgent; + const test = setOnAny(validBidRequests, 'params.test'); + const publisher = setOnAny(validBidRequests, 'params.publisher'); + const bcat = setOnAny(validBidRequests, 'params.bcat'); + const badv = setOnAny(validBidRequests, 'params.badv'); + const cur = CURRENCY; + const endpointUrl = config.getConfig('outbrain.bidderUrl'); + const timeout = bidderRequest.timeout; + + const imps = validBidRequests.map((bid, id) => { + bid.netRevenue = 'net'; + const imp = { + id: id + 1 + '' + } + + if (bid.params.tagid) { + imp.tagid = bid.params.tagid + } + + if (bid.nativeParams) { + imp.native = { + request: JSON.stringify({ + assets: getNativeAssets(bid) + }) + } + } else { + imp.banner = { + format: transformSizes(bid.sizes) + } + } + + return imp; + }); + + const request = { + id: bidderRequest.auctionId, + site: { page, publisher }, + device: { ua }, + source: { fd: 1 }, + cur: [cur], + tmax: timeout, + imp: imps, + bcat: bcat, + badv: badv, + }; + + if (test) { + request.is_debug = !!test; + request.test = 1; + } + + if (utils.deepAccess(bidderRequest, 'gdprConsent.gdprApplies')) { + utils.deepSetValue(request, 'user.ext.consent', bidderRequest.gdprConsent.consentString) + utils.deepSetValue(request, 'regs.ext.gdpr', bidderRequest.gdprConsent.gdprApplies & 1) + } + if (bidderRequest.uspConsent) { + utils.deepSetValue(request, 'regs.ext.us_privacy', bidderRequest.uspConsent) + } + if (config.getConfig('coppa') === true) { + utils.deepSetValue(request, 'regs.coppa', config.getConfig('coppa') & 1) + } + + return { + method: 'POST', + url: endpointUrl, + data: JSON.stringify(request), + bids: validBidRequests + }; + }, + interpretResponse: (serverResponse, { bids }) => { + if (!serverResponse.body) { + return []; + } + const { seatbid, cur } = serverResponse.body; + + const bidResponses = flatten(seatbid.map(seat => seat.bid)).reduce((result, bid) => { + result[bid.impid - 1] = bid; + return result; + }, []); + + return bids.map((bid, id) => { + const bidResponse = bidResponses[id]; + if (bidResponse) { + const type = bid.nativeParams ? NATIVE : BANNER; + const bidObject = { + requestId: bid.bidId, + cpm: bidResponse.price, + creativeId: bidResponse.crid, + ttl: 360, + netRevenue: bid.netRevenue === 'net', + currency: cur, + mediaType: type, + nurl: bidResponse.nurl, + }; + if (type === NATIVE) { + bidObject.native = parseNative(bidResponse); + } else { + bidObject.ad = bidResponse.adm; + bidObject.width = bidResponse.w; + bidObject.height = bidResponse.h; + } + bidObject.meta = {}; + if (bidResponse.adomain && bidResponse.adomain.length > 0) { + bidObject.meta.advertiserDomains = bidResponse.adomain; + } + return bidObject; + } + }).filter(Boolean); + }, + getUserSyncs: (syncOptions, responses, gdprConsent, uspConsent) => { + const syncs = []; + let syncUrl = config.getConfig('outbrain.usersyncUrl'); + if (syncOptions.pixelEnabled && syncUrl) { + if (gdprConsent) { + syncUrl += '&gdpr=' + (gdprConsent.gdprApplies & 1); + syncUrl += '&gdpr_consent=' + encodeURIComponent(gdprConsent.consentString || ''); + } + if (uspConsent) { + syncUrl += '&us_privacy=' + encodeURIComponent(uspConsent); + } + + syncs.push({ + type: 'image', + url: syncUrl + }); + } + return syncs; + }, + onBidWon: (bid) => { + ajax(utils.replaceAuctionPrice(bid.nurl, bid.originalCpm)) + } +}; + +registerBidder(spec); + +function parseNative(bid) { + const { assets, link, eventtrackers } = JSON.parse(bid.adm); + const result = { + clickUrl: link.url, + clickTrackers: link.clicktrackers || undefined + }; + assets.forEach(asset => { + const kind = NATIVE_ASSET_IDS[asset.id]; + const content = kind && asset[NATIVE_PARAMS[kind].name]; + if (content) { + result[kind] = content.text || content.value || { url: content.url, width: content.w, height: content.h }; + } + }); + if (eventtrackers) { + result.impressionTrackers = []; + eventtrackers.forEach(tracker => { + if (tracker.event !== 1) return; + switch (tracker.method) { + case 1: // img + result.impressionTrackers.push(tracker.url); + break; + case 2: // js + result.javascriptTrackers = ``; + break; + } + }); + } + return result; +} + +function setOnAny(collection, key) { + for (let i = 0, result; i < collection.length; i++) { + result = utils.deepAccess(collection[i], key); + if (result) { + return result; + } + } +} + +function flatten(arr) { + return [].concat(...arr); +} + +function getNativeAssets(bid) { + return utils._map(bid.nativeParams, (bidParams, key) => { + const props = NATIVE_PARAMS[key]; + const asset = { + required: bidParams.required & 1, + }; + if (props) { + asset.id = props.id; + let wmin, hmin, w, h; + let aRatios = bidParams.aspect_ratios; + + if (aRatios && aRatios[0]) { + aRatios = aRatios[0]; + wmin = aRatios.min_width || 0; + hmin = aRatios.ratio_height * wmin / aRatios.ratio_width | 0; + } + + if (bidParams.sizes) { + const sizes = flatten(bidParams.sizes); + w = sizes[0]; + h = sizes[1]; + } + + asset[props.name] = { + len: bidParams.len, + type: props.type, + wmin, + hmin, + w, + h + }; + + return asset; + } + }).filter(Boolean); +} + +/* Turn bid request sizes into ut-compatible format */ +function transformSizes(requestSizes) { + if (!utils.isArray(requestSizes)) { + return []; + } + + if (requestSizes.length === 2 && !utils.isArray(requestSizes[0])) { + return [{ + w: parseInt(requestSizes[0], 10), + h: parseInt(requestSizes[1], 10) + }]; + } else if (utils.isArray(requestSizes[0])) { + return requestSizes.map(item => + ({ + w: parseInt(item[0], 10), + h: parseInt(item[1], 10) + }) + ); + } + + return []; +} diff --git a/modules/outbrainBidAdapter.md b/modules/outbrainBidAdapter.md new file mode 100644 index 00000000000..32df22ddad8 --- /dev/null +++ b/modules/outbrainBidAdapter.md @@ -0,0 +1,111 @@ +# Overview + +``` +Module Name: Outbrain Adapter +Module Type: Bidder Adapter +Maintainer: prog-ops-team@outbrain.com +``` + +# Description + +Module that connects to Outbrain bidder to fetch bids. +Both native and display formats are supported but not at the same time. Using OpenRTB standard. + +# Configuration + +## Bidder and usersync URLs + +The Outbrain adapter does not work without setting the correct bidder and usersync URLs. +You will receive the URLs when contacting us. + +``` +pbjs.setConfig({ + outbrain: { + bidderUrl: 'https://bidder-url.com', + usersyncUrl: 'https://usersync-url.com' + } +}); +``` + + +# Test Native Parameters +``` + var adUnits = [ + code: '/19968336/prebid_native_example_1', + mediaTypes: { + native: { + image: { + required: false, + sizes: [100, 50] + }, + title: { + required: false, + len: 140 + }, + sponsoredBy: { + required: false + }, + clickUrl: { + required: false + }, + body: { + required: false + }, + icon: { + required: false, + sizes: [50, 50] + } + } + }, + bids: [{ + bidder: 'outbrain', + params: { + publisher: { + id: '2706', // required + name: 'Publishers Name', + domain: 'publisher.com' + }, + tagid: 'tag-id', + bcat: ['IAB1-1'], + badv: ['example.com'] + } + }] + ]; + + pbjs.setConfig({ + outbrain: { + bidderUrl: 'https://prebidtest.zemanta.com/api/bidder/prebidtest/bid/' + } + }); +``` + +# Test Display Parameters +``` + var adUnits = [ + code: '/19968336/prebid_display_example_1', + mediaTypes: { + banner: { + sizes: [[300, 250]] + } + }, + bids: [{ + bidder: 'outbrain', + params: { + publisher: { + id: '2706', // required + name: 'Publishers Name', + domain: 'publisher.com' + }, + tagid: 'tag-id', + bcat: ['IAB1-1'], + badv: ['example.com'] + }, + }] + ]; + + pbjs.setConfig({ + outbrain: { + bidderUrl: 'https://prebidtest.zemanta.com/api/bidder/prebidtest/bid/' + } + }); +``` diff --git a/modules/zemantaBidAdapter.js b/modules/zemantaBidAdapter.js index b6dafcaed77..a787fab7d86 100644 --- a/modules/zemantaBidAdapter.js +++ b/modules/zemantaBidAdapter.js @@ -25,9 +25,6 @@ const NATIVE_PARAMS = { export const spec = { code: BIDDER_CODE, gvlid: GVLID, - aliases: [ - { code: 'outbrain', gvlid: GVLID } - ], supportedMediaTypes: [ NATIVE, BANNER ], isBidRequestValid: (bid) => { return ( diff --git a/test/spec/modules/outbrainBidAdapter_spec.js b/test/spec/modules/outbrainBidAdapter_spec.js new file mode 100644 index 00000000000..9671adf826e --- /dev/null +++ b/test/spec/modules/outbrainBidAdapter_spec.js @@ -0,0 +1,558 @@ +import {expect} from 'chai'; +import {spec} from 'modules/outbrainBidAdapter.js'; +import {config} from 'src/config.js'; +import {server} from 'test/mocks/xhr'; + +describe('Outbrain Adapter', function () { + describe('Bid request and response', function () { + const commonBidRequest = { + bidder: 'outbrain', + params: { + publisher: { + id: 'publisher-id' + }, + }, + bidId: '2d6815a92ba1ba', + auctionId: '12043683-3254-4f74-8934-f941b085579e', + } + const nativeBidRequestParams = { + nativeParams: { + image: { + required: true, + sizes: [ + 120, + 100 + ], + sendId: true + }, + title: { + required: true, + sendId: true + }, + sponsoredBy: { + required: false + } + }, + } + + const displayBidRequestParams = { + sizes: [ + [ + 300, + 250 + ] + ] + } + + describe('isBidRequestValid', function () { + before(() => { + config.setConfig({ + outbrain: { + bidderUrl: 'https://bidder-url.com', + } + } + ) + }) + after(() => { + config.resetConfig() + }) + + it('should fail when bid is invalid', function () { + const bid = { + bidder: 'outbrain', + params: { + publisher: { + id: 'publisher-id', + } + }, + } + expect(spec.isBidRequestValid(bid)).to.equal(false) + }) + it('should succeed when bid contains native params', function () { + const bid = { + bidder: 'outbrain', + params: { + publisher: { + id: 'publisher-id', + } + }, + ...nativeBidRequestParams, + } + expect(spec.isBidRequestValid(bid)).to.equal(true) + }) + it('should succeed when bid contains sizes', function () { + const bid = { + bidder: 'outbrain', + params: { + publisher: { + id: 'publisher-id', + } + }, + ...displayBidRequestParams, + } + expect(spec.isBidRequestValid(bid)).to.equal(true) + }) + it('should fail if publisher id is not set', function () { + const bid = { + bidder: 'outbrain', + ...nativeBidRequestParams, + } + expect(spec.isBidRequestValid(bid)).to.equal(false) + }) + it('should succeed with outbrain config', function () { + const bid = { + bidder: 'outbrain', + params: { + publisher: { + id: 'publisher-id', + } + }, + ...nativeBidRequestParams, + } + config.resetConfig() + config.setConfig({ + outbrain: { + bidderUrl: 'https://bidder-url.com', + } + }) + expect(spec.isBidRequestValid(bid)).to.equal(true) + }) + it('should fail if bidder url is not set', function () { + const bid = { + bidder: 'outbrain', + params: { + publisher: { + id: 'publisher-id', + } + }, + ...nativeBidRequestParams, + } + config.resetConfig() + expect(spec.isBidRequestValid(bid)).to.equal(false) + }) + }) + + describe('buildRequests', function () { + before(() => { + config.setConfig({ + outbrain: { + bidderUrl: 'https://bidder-url.com', + } + } + ) + }) + after(() => { + config.resetConfig() + }) + + const commonBidderRequest = { + refererInfo: { + referer: 'https://example.com/' + } + } + + it('should build native request', function () { + const bidRequest = { + ...commonBidRequest, + ...nativeBidRequestParams, + } + const expectedNativeAssets = { + assets: [ + { + required: 1, + id: 3, + img: { + type: 3, + w: 120, + h: 100 + } + }, + { + required: 1, + id: 0, + title: {} + }, + { + required: 0, + id: 5, + data: { + type: 1 + } + } + ] + } + const expectedData = { + site: { + page: 'https://example.com/', + publisher: { + id: 'publisher-id' + } + }, + device: { + ua: navigator.userAgent + }, + source: { + fd: 1 + }, + cur: [ + 'USD' + ], + imp: [ + { + id: '1', + native: { + request: JSON.stringify(expectedNativeAssets) + } + } + ] + } + const res = spec.buildRequests([bidRequest], commonBidderRequest) + expect(res.url).to.equal('https://bidder-url.com') + expect(res.data).to.deep.equal(JSON.stringify(expectedData)) + }); + + it('should build display request', function () { + const bidRequest = { + ...commonBidRequest, + ...displayBidRequestParams, + } + const expectedData = { + site: { + page: 'https://example.com/', + publisher: { + id: 'publisher-id' + } + }, + device: { + ua: navigator.userAgent + }, + source: { + fd: 1 + }, + cur: [ + 'USD' + ], + imp: [ + { + id: '1', + banner: { + format: [ + { + w: 300, + h: 250 + } + ] + } + } + ] + } + const res = spec.buildRequests([bidRequest], commonBidderRequest) + expect(res.url).to.equal('https://bidder-url.com') + expect(res.data).to.deep.equal(JSON.stringify(expectedData)) + }) + + it('should pass optional parameters in request', function () { + const bidRequest = { + ...commonBidRequest, + ...nativeBidRequestParams, + } + bidRequest.params.tagid = 'test-tag' + bidRequest.params.publisher.name = 'test-publisher' + bidRequest.params.publisher.domain = 'test-publisher.com' + bidRequest.params.bcat = ['bad-category'] + bidRequest.params.badv = ['bad-advertiser'] + + const res = spec.buildRequests([bidRequest], commonBidderRequest) + const resData = JSON.parse(res.data) + expect(resData.imp[0].tagid).to.equal('test-tag') + expect(resData.site.publisher.name).to.equal('test-publisher') + expect(resData.site.publisher.domain).to.equal('test-publisher.com') + expect(resData.bcat).to.deep.equal(['bad-category']) + expect(resData.badv).to.deep.equal(['bad-advertiser']) + }); + + it('should pass bidder timeout', function () { + const bidRequest = { + ...commonBidRequest, + ...nativeBidRequestParams, + } + const bidderRequest = { + ...commonBidderRequest, + timeout: 500 + } + const res = spec.buildRequests([bidRequest], bidderRequest) + const resData = JSON.parse(res.data) + expect(resData.tmax).to.equal(500) + }); + + it('should pass GDPR consent', function () { + const bidRequest = { + ...commonBidRequest, + ...nativeBidRequestParams, + } + const bidderRequest = { + ...commonBidderRequest, + gdprConsent: { + gdprApplies: true, + consentString: 'consentString', + } + } + const res = spec.buildRequests([bidRequest], bidderRequest) + const resData = JSON.parse(res.data) + expect(resData.user.ext.consent).to.equal('consentString') + expect(resData.regs.ext.gdpr).to.equal(1) + }); + + it('should pass us privacy consent', function () { + const bidRequest = { + ...commonBidRequest, + ...nativeBidRequestParams, + } + const bidderRequest = { + ...commonBidderRequest, + uspConsent: 'consentString' + } + const res = spec.buildRequests([bidRequest], bidderRequest) + const resData = JSON.parse(res.data) + expect(resData.regs.ext.us_privacy).to.equal('consentString') + }); + + it('should pass coppa consent', function () { + const bidRequest = { + ...commonBidRequest, + ...nativeBidRequestParams, + } + config.setConfig({coppa: true}) + + const res = spec.buildRequests([bidRequest], commonBidderRequest) + const resData = JSON.parse(res.data) + expect(resData.regs.coppa).to.equal(1) + + config.resetConfig() + }); + }) + + describe('interpretResponse', function () { + it('should return empty array if no valid bids', function () { + const res = spec.interpretResponse({}, []) + expect(res).to.be.an('array').that.is.empty + }); + + it('should interpret native response', function () { + const serverResponse = { + body: { + id: '0a73e68c-9967-4391-b01b-dda2d9fc54e4', + seatbid: [ + { + bid: [ + { + id: '82822cf5-259c-11eb-8a52-f29e5275aa57', + impid: '1', + price: 1.1, + nurl: 'http://example.com/win/${AUCTION_PRICE}', + adm: '{"ver":"1.2","assets":[{"id":3,"required":1,"img":{"url":"http://example.com/img/url","w":120,"h":100}},{"id":0,"required":1,"title":{"text":"Test title"}},{"id":5,"data":{"value":"Test sponsor"}}],"link":{"url":"http://example.com/click/url"},"eventtrackers":[{"event":1,"method":1,"url":"http://example.com/impression"}]}', + adomain: [ + 'example.com' + ], + cid: '3487171', + crid: '28023739', + cat: [ + 'IAB10-2' + ] + } + ], + seat: 'acc-5537' + } + ], + bidid: '82822cf5-259c-11eb-8a52-b48e7518c657', + cur: 'USD' + }, + } + const request = { + bids: [ + { + ...commonBidRequest, + ...nativeBidRequestParams, + } + ] + } + const expectedRes = [ + { + requestId: request.bids[0].bidId, + cpm: 1.1, + creativeId: '28023739', + ttl: 360, + netRevenue: false, + currency: 'USD', + mediaType: 'native', + nurl: 'http://example.com/win/${AUCTION_PRICE}', + meta: { + 'advertiserDomains': [ + 'example.com' + ] + }, + native: { + clickTrackers: undefined, + clickUrl: 'http://example.com/click/url', + image: { + url: 'http://example.com/img/url', + width: 120, + height: 100 + }, + title: 'Test title', + sponsoredBy: 'Test sponsor', + impressionTrackers: [ + 'http://example.com/impression', + ] + } + } + ] + + const res = spec.interpretResponse(serverResponse, request) + expect(res).to.deep.equal(expectedRes) + }); + + it('should interpret display response', function () { + const serverResponse = { + body: { + id: '6b2eedc8-8ff5-46ef-adcf-e701b508943e', + seatbid: [ + { + bid: [ + { + id: 'd90fe7fa-28d7-11eb-8ce4-462a842a7cf9', + impid: '1', + price: 1.1, + nurl: 'http://example.com/win/${AUCTION_PRICE}', + adm: '