diff --git a/modules/criteoBidAdapter.js b/modules/criteoBidAdapter.js new file mode 100755 index 00000000000..ee74141f0bc --- /dev/null +++ b/modules/criteoBidAdapter.js @@ -0,0 +1,262 @@ +import { loadExternalScript } from 'src/adloader'; +import { registerBidder } from 'src/adapters/bidderFactory'; +import { parse } from 'src/url'; +import * as utils from 'src/utils'; + +const ADAPTER_VERSION = 7; +const BIDDER_CODE = 'criteo'; +const CDB_ENDPOINT = '//bidder.criteo.com/cdb'; +const CRITEO_VENDOR_ID = 91; +const INTEGRATION_MODES = { + 'amp': 1, +}; +const PROFILE_ID = 207; + +// Unminified source code can be found in: https://github.com/Prebid-org/prebid-js-external-js-criteo/blob/master/dist/prod.js +const PUBLISHER_TAG_URL = '//static.criteo.net/js/ld/publishertag.prebid.js'; + +/** @type {BidderSpec} */ +export const spec = { + code: BIDDER_CODE, + + /** + * @param {object} bid + * @return {boolean} + */ + isBidRequestValid: bid => ( + !!(bid && bid.params && (bid.params.zoneId || bid.params.networkId)) + ), + + /** + * @param {BidRequest[]} bidRequests + * @param {*} bidderRequest + * @return {ServerRequest} + */ + buildRequests: (bidRequests, bidderRequest) => { + let url; + let data; + + // If publisher tag not already loaded try to get it from fast bid + if (!publisherTagAvailable()) { + window.Criteo = window.Criteo || {}; + window.Criteo.usePrebidEvents = false; + + tryGetCriteoFastBid(); + + // Reload the PublisherTag after the timeout to ensure FastBid is up-to-date and tracking done properly + setTimeout(() => { + loadExternalScript(PUBLISHER_TAG_URL, BIDDER_CODE); + }, bidderRequest.timeout); + } + + if (publisherTagAvailable()) { + const adapter = new Criteo.PubTag.Adapters.Prebid(PROFILE_ID, ADAPTER_VERSION, bidRequests, bidderRequest); + url = adapter.buildCdbUrl(); + data = adapter.buildCdbRequest(); + } else { + const context = buildContext(bidRequests); + url = buildCdbUrl(context); + data = buildCdbRequest(context, bidRequests, bidderRequest); + } + + if (data) { + return { method: 'POST', url, data, bidRequests }; + } + }, + + /** + * @param {*} response + * @param {ServerRequest} request + * @return {Bid[]} + */ + interpretResponse: (response, request) => { + const body = response.body || response; + + if (publisherTagAvailable()) { + const adapter = Criteo.PubTag.Adapters.Prebid.GetAdapter(request); + if (adapter) { + return adapter.interpretResponse(body, request); + } + } + + const bids = []; + + if (body && body.slots && utils.isArray(body.slots)) { + body.slots.forEach(slot => { + const bidRequest = request.bidRequests.find(b => b.adUnitCode === slot.impid && (!b.params.zoneId || parseInt(b.params.zoneId) === slot.zoneid)); + const bidId = bidRequest.bidId; + const bid = { + requestId: bidId, + cpm: slot.cpm, + currency: slot.currency, + netRevenue: true, + ttl: slot.ttl || 60, + creativeId: bidId, + width: slot.width, + height: slot.height, + } + if (slot.native) { + bid.ad = createNativeAd(bidId, slot.native, bidRequest.params.nativeCallback); + } else { + bid.ad = slot.creative; + } + bids.push(bid); + }); + } + + return bids; + }, + + /** + * @param {TimedOutBid} timeoutData + */ + onTimeout: (timeoutData) => { + if (publisherTagAvailable()) { + const adapter = Criteo.PubTag.Adapters.Prebid.GetAdapter(timeoutData.auctionId); + adapter.handleBidTimeout(); + } + }, +}; + +/** + * @return {boolean} + */ +function publisherTagAvailable() { + return typeof Criteo !== 'undefined' && Criteo.PubTag && Criteo.PubTag.Adapters && Criteo.PubTag.Adapters.Prebid; +} + +/** + * @param {BidRequest[]} bidRequests + * @return {CriteoContext} + */ +function buildContext(bidRequests) { + const url = utils.getTopWindowUrl(); + const queryString = parse(url).search; + + const context = { + url: url, + debug: queryString['pbt_debug'] === '1', + noLog: queryString['pbt_nolog'] === '1', + integrationMode: undefined, + }; + + bidRequests.forEach(bidRequest => { + if (bidRequest.params.integrationMode) { + context.integrationMode = bidRequest.params.integrationMode; + } + }) + + return context; +} + +/** + * @param {CriteoContext} context + * @return {string} + */ +function buildCdbUrl(context) { + let url = CDB_ENDPOINT; + url += '?profileId=' + PROFILE_ID; + url += '&av=' + String(ADAPTER_VERSION); + url += '&cb=' + String(Math.floor(Math.random() * 99999999999)); + + if (context.integrationMode in INTEGRATION_MODES) { + url += '&im=' + INTEGRATION_MODES[context.integrationMode]; + } + if (context.debug) { + url += '&debug=1'; + } + if (context.noLog) { + url += '&nolog=1'; + } + + return url; +} + +/** + * @param {CriteoContext} context + * @param {BidRequest[]} bidRequests + * @return {*} + */ +function buildCdbRequest(context, bidRequests, bidderRequest) { + let networkId; + const request = { + publisher: { + url: context.url, + }, + slots: bidRequests.map(bidRequest => { + networkId = bidRequest.params.networkId || networkId; + const slot = { + impid: bidRequest.adUnitCode, + transactionid: bidRequest.transactionId, + auctionId: bidRequest.auctionId, + sizes: bidRequest.sizes.map(size => size[0] + 'x' + size[1]), + }; + if (bidRequest.params.zoneId) { + slot.zoneid = bidRequest.params.zoneId; + } + if (bidRequest.params.publisherSubId) { + slot.publishersubid = bidRequest.params.publisherSubId; + } + if (bidRequest.params.nativeCallback) { + slot.native = true; + } + return slot; + }), + }; + if (networkId) { + request.publisher.networkid = networkId; + } + if (bidderRequest && bidderRequest.gdprConsent) { + request.gdprConsent = { + gdprApplies: !!(bidderRequest.gdprConsent.gdprApplies), + consentData: bidderRequest.gdprConsent.consentString, + consentGiven: !!(bidderRequest.gdprConsent.vendorData && bidderRequest.gdprConsent.vendorData.vendorConsents && + bidderRequest.gdprConsent.vendorData.vendorConsents[ CRITEO_VENDOR_ID.toString(10) ]), + }; + } + return request; +} + +/** + * @param {string} id + * @param {*} payload + * @param {*} callback + * @return {string} + */ +function createNativeAd(id, payload, callback) { + // Store the callback and payload in a global object to be later accessed from the creative + window.criteo_prebid_native_slots = window.criteo_prebid_native_slots || {}; + window.criteo_prebid_native_slots[id] = { callback, payload }; + + // The creative is in an iframe so we have to get the callback and payload + // from the parent window (doesn't work with safeframes) + return ``; +} + +/** + * @return {boolean} + */ +function tryGetCriteoFastBid() { + try { + const fastBid = localStorage.getItem('criteo_fast_bid'); + if (fastBid !== null) { + eval(fastBid); // eslint-disable-line no-eval + return true; + } + } catch (e) { + // Unable to get fast bid + } + return false; +} + +registerBidder(spec); diff --git a/modules/criteoBidAdapter.md b/modules/criteoBidAdapter.md new file mode 100755 index 00000000000..796c70a980f --- /dev/null +++ b/modules/criteoBidAdapter.md @@ -0,0 +1,27 @@ +# Overview + +Module Name: Criteo Bidder Adapter +Module Type: Bidder Adapter +Maintainer: pi-direct@criteo.com + +# Description + +Module that connects to Criteo's demand sources. + +# Test Parameters +``` + var adUnits = [ + { + code: 'banner-ad-div', + sizes: [[300, 250], [728, 90]], + bids: [ + { + bidder: 'criteo', + params: { + zoneId: 497747 + } + } + ] + } + ]; +``` diff --git a/test/spec/modules/criteoBidAdapter_spec.js b/test/spec/modules/criteoBidAdapter_spec.js new file mode 100755 index 00000000000..ccf683dc4ca --- /dev/null +++ b/test/spec/modules/criteoBidAdapter_spec.js @@ -0,0 +1,274 @@ +import { expect } from 'chai'; +import { spec } from 'modules/criteoBidAdapter'; +import * as utils from 'src/utils'; + +describe('The Criteo bidding adapter', () => { + describe('isBidRequestValid', () => { + it('should return false when given an invalid bid', () => { + const bid = { + bidder: 'criteo', + }; + const isValid = spec.isBidRequestValid(bid); + expect(isValid).to.equal(false); + }); + + it('should return true when given a zoneId bid', () => { + const bid = { + bidder: 'criteo', + params: { + zoneId: 123, + }, + }; + const isValid = spec.isBidRequestValid(bid); + expect(isValid).to.equal(true); + }); + + it('should return true when given a networkId bid', () => { + const bid = { + bidder: 'criteo', + params: { + networkId: 456, + }, + }; + const isValid = spec.isBidRequestValid(bid); + expect(isValid).to.equal(true); + }); + + it('should return true when given a mixed bid with both a zoneId and a networkId', () => { + const bid = { + bidder: 'criteo', + params: { + zoneId: 123, + networkId: 456, + }, + }; + const isValid = spec.isBidRequestValid(bid); + expect(isValid).to.equal(true); + }); + }); + + describe('buildRequests', () => { + const bidderRequest = { timeout: 3000, + gdprConsent: { + gdprApplies: 1, + consentString: 'concentDataString', + vendorData: { + vendorConsents: { + '91': 1 + }, + }, + }, + }; + + it('should properly build a zoneId request', () => { + const bidRequests = [ + { + bidder: 'criteo', + adUnitCode: 'bid-123', + transactionId: 'transaction-123', + sizes: [[728, 90]], + params: { + zoneId: 123, + }, + }, + ]; + const request = spec.buildRequests(bidRequests, bidderRequest); + expect(request.url).to.match(/^\/\/bidder\.criteo\.com\/cdb\?profileId=207&av=\d+&cb=\d/); + expect(request.method).to.equal('POST'); + const ortbRequest = request.data; + expect(ortbRequest.publisher.url).to.equal(utils.getTopWindowUrl()); + expect(ortbRequest.slots).to.have.lengthOf(1); + expect(ortbRequest.slots[0].impid).to.equal('bid-123'); + expect(ortbRequest.slots[0].transactionid).to.equal('transaction-123'); + expect(ortbRequest.slots[0].sizes).to.have.lengthOf(1); + expect(ortbRequest.slots[0].sizes[0]).to.equal('728x90'); + expect(ortbRequest.slots[0].zoneid).to.equal(123); + expect(ortbRequest.gdprConsent.consentData).to.equal('concentDataString'); + expect(ortbRequest.gdprConsent.gdprApplies).to.equal(true); + expect(ortbRequest.gdprConsent.consentGiven).to.equal(true); + }); + + it('should properly build a networkId request', () => { + const bidderRequest = { + timeout: 3000, + gdprConsent: { + gdprApplies: 0, + consentString: undefined, + vendorData: { + vendorConsents: { + '1': 0 + }, + }, + }, + }; + const bidRequests = [ + { + bidder: 'criteo', + adUnitCode: 'bid-123', + transactionId: 'transaction-123', + sizes: [[300, 250], [728, 90]], + params: { + networkId: 456, + }, + }, + ]; + const request = spec.buildRequests(bidRequests, bidderRequest); + expect(request.url).to.match(/^\/\/bidder\.criteo\.com\/cdb\?profileId=207&av=\d+&cb=\d/); + expect(request.method).to.equal('POST'); + const ortbRequest = request.data; + expect(ortbRequest.publisher.url).to.equal(utils.getTopWindowUrl()); + expect(ortbRequest.publisher.networkid).to.equal(456); + expect(ortbRequest.slots).to.have.lengthOf(1); + expect(ortbRequest.slots[0].impid).to.equal('bid-123'); + expect(ortbRequest.slots[0].transactionid).to.equal('transaction-123'); + expect(ortbRequest.slots[0].sizes).to.have.lengthOf(2); + expect(ortbRequest.slots[0].sizes[0]).to.equal('300x250'); + expect(ortbRequest.slots[0].sizes[1]).to.equal('728x90'); + expect(ortbRequest.gdprConsent.consentData).to.equal(undefined); + expect(ortbRequest.gdprConsent.gdprApplies).to.equal(false); + expect(ortbRequest.gdprConsent.consentGiven).to.equal(false); + }); + + it('should properly build a mixed request', () => { + const bidderRequest = { timeout: 3000 }; + const bidRequests = [ + { + bidder: 'criteo', + adUnitCode: 'bid-123', + transactionId: 'transaction-123', + sizes: [[728, 90]], + params: { + zoneId: 123, + }, + }, + { + bidder: 'criteo', + adUnitCode: 'bid-234', + transactionId: 'transaction-234', + sizes: [[300, 250], [728, 90]], + params: { + networkId: 456, + }, + }, + ]; + const request = spec.buildRequests(bidRequests, bidderRequest); + expect(request.url).to.match(/^\/\/bidder\.criteo\.com\/cdb\?profileId=207&av=\d+&cb=\d/); + expect(request.method).to.equal('POST'); + const ortbRequest = request.data; + expect(ortbRequest.publisher.url).to.equal(utils.getTopWindowUrl()); + expect(ortbRequest.publisher.networkid).to.equal(456); + expect(ortbRequest.slots).to.have.lengthOf(2); + expect(ortbRequest.slots[0].impid).to.equal('bid-123'); + expect(ortbRequest.slots[0].transactionid).to.equal('transaction-123'); + expect(ortbRequest.slots[0].sizes).to.have.lengthOf(1); + expect(ortbRequest.slots[0].sizes[0]).to.equal('728x90'); + expect(ortbRequest.slots[1].impid).to.equal('bid-234'); + expect(ortbRequest.slots[1].transactionid).to.equal('transaction-234'); + expect(ortbRequest.slots[1].sizes).to.have.lengthOf(2); + expect(ortbRequest.slots[1].sizes[0]).to.equal('300x250'); + expect(ortbRequest.slots[1].sizes[1]).to.equal('728x90'); + expect(ortbRequest.gdprConsent).to.equal(undefined); + }); + }); + + describe('interpretResponse', () => { + it('should return an empty array when parsing a no bid response', () => { + const response = {}; + const request = { bidRequests: [] }; + const bids = spec.interpretResponse(response, request); + expect(bids).to.have.lengthOf(0); + }); + + it('should properly parse a bid response with a networkId', () => { + const response = { + body: { + slots: [{ + impid: 'test-requestId', + cpm: 1.23, + creative: 'test-ad', + width: 728, + height: 90, + }], + }, + }; + const request = { + bidRequests: [{ + adUnitCode: 'test-requestId', + bidId: 'test-bidId', + params: { + networkId: 456, + } + }] + }; + const bids = spec.interpretResponse(response, request); + expect(bids).to.have.lengthOf(1); + expect(bids[0].requestId).to.equal('test-bidId'); + expect(bids[0].cpm).to.equal(1.23); + expect(bids[0].ad).to.equal('test-ad'); + expect(bids[0].width).to.equal(728); + expect(bids[0].height).to.equal(90); + }); + + it('should properly parse a bid responsewith with a zoneId', () => { + const response = { + body: { + slots: [{ + impid: 'test-requestId', + cpm: 1.23, + creative: 'test-ad', + width: 728, + height: 90, + zoneid: 123, + }], + }, + }; + const request = { + bidRequests: [{ + adUnitCode: 'test-requestId', + bidId: 'test-bidId', + params: { + zoneId: 123, + }, + }] + }; + const bids = spec.interpretResponse(response, request); + expect(bids).to.have.lengthOf(1); + expect(bids[0].requestId).to.equal('test-bidId'); + expect(bids[0].cpm).to.equal(1.23); + expect(bids[0].ad).to.equal('test-ad'); + expect(bids[0].width).to.equal(728); + expect(bids[0].height).to.equal(90); + }); + + it('should properly parse a bid responsewith with a zoneId passed as a string', () => { + const response = { + body: { + slots: [{ + impid: 'test-requestId', + cpm: 1.23, + creative: 'test-ad', + width: 728, + height: 90, + zoneid: 123, + }], + }, + }; + const request = { + bidRequests: [{ + adUnitCode: 'test-requestId', + bidId: 'test-bidId', + params: { + zoneId: '123', + }, + }] + }; + const bids = spec.interpretResponse(response, request); + expect(bids).to.have.lengthOf(1); + expect(bids[0].requestId).to.equal('test-bidId'); + expect(bids[0].cpm).to.equal(1.23); + expect(bids[0].ad).to.equal('test-ad'); + expect(bids[0].width).to.equal(728); + expect(bids[0].height).to.equal(90); + }); + }); +});