From 0f3a8535d38c3658689cad1860c903a36b682fb7 Mon Sep 17 00:00:00 2001 From: Yuki Tsujii Date: Tue, 20 Aug 2024 02:15:19 +0900 Subject: [PATCH] add media consortium adapter (#11892) Co-authored-by: Maxime Lequain --- modules/mediaConsortiumBidAdapter.js | 273 ++++++++++++ modules/mediaConsortiumBidAdapter.md | 79 ++++ .../modules/mediaConsortiumBidAdapter_spec.js | 411 ++++++++++++++++++ 3 files changed, 763 insertions(+) create mode 100644 modules/mediaConsortiumBidAdapter.js create mode 100644 modules/mediaConsortiumBidAdapter.md create mode 100644 test/spec/modules/mediaConsortiumBidAdapter_spec.js diff --git a/modules/mediaConsortiumBidAdapter.js b/modules/mediaConsortiumBidAdapter.js new file mode 100644 index 00000000000..a1cd6586735 --- /dev/null +++ b/modules/mediaConsortiumBidAdapter.js @@ -0,0 +1,273 @@ +import {BANNER, VIDEO} from '../src/mediaTypes.js' +import {registerBidder} from '../src/adapters/bidderFactory.js' +import {generateUUID, isPlainObject, isArray, logWarn, deepClone} from '../src/utils.js' +import {Renderer} from '../src/Renderer.js' +import {OUTSTREAM} from '../src/video.js' +import {config} from '../src/config.js'; +import { getStorageManager } from '../src/storageManager.js'; + +const BIDDER_CODE = 'mediaConsortium' + +const PROFILE_API_USAGE_CONFIG_KEY = 'useProfileApi' +const ONE_PLUS_X_ID_USAGE_CONFIG_KEY = 'readOnePlusXId' + +const SYNC_ENDPOINT = 'https://relay.hubvisor.io/v1/sync/big' +const AUCTION_ENDPOINT = 'https://relay.hubvisor.io/v1/auction/big' + +const XANDR_OUTSTREAM_RENDERER_URL = 'https://acdn.adnxs.com/video/outstream/ANOutstreamVideo.js'; + +export const OPTIMIZATIONS_STORAGE_KEY = 'media_consortium_optimizations' + +const SYNC_TYPES = { + image: 'image', + redirect: 'image', + iframe: 'iframe' +} + +const storageManager = getStorageManager({ bidderCode: BIDDER_CODE }); + +export const spec = { + version: '0.0.1', + code: BIDDER_CODE, + gvlid: 1112, + supportedMediaTypes: [BANNER, VIDEO], + isBidRequestValid(bid) { + return true + }, + buildRequests(bidRequests, bidderRequest) { + const useProfileApi = config.getConfig(PROFILE_API_USAGE_CONFIG_KEY) ?? false + const readOnePlusXId = config.getConfig(ONE_PLUS_X_ID_USAGE_CONFIG_KEY) ?? false + + const { + auctionId, + bids, + gdprConsent: {gdprApplies = false, consentString} = {}, + ortb2: {device, site} + } = bidderRequest + + const currentTimestamp = Date.now() + const optimizations = getOptimizationsFromLocalStorage() + + const impressions = bids.reduce((acc, bidRequest) => { + const {bidId, adUnitCode, mediaTypes} = bidRequest + const optimization = optimizations[adUnitCode] + + if (optimization) { + const {expiresAt, isEnabled} = optimization + + if (expiresAt >= currentTimestamp && !isEnabled) { + return acc + } + } + + let finalizedMediatypes = deepClone(mediaTypes) + + if (mediaTypes.video && mediaTypes.video.context !== OUTSTREAM) { + logWarn(`Filtering video request for adUnitCode ${adUnitCode} because context is not ${OUTSTREAM}`) + + if (Object.keys(finalizedMediatypes).length > 1) { + delete finalizedMediatypes.video + } else { + return acc + } + } + + return acc.concat({id: bidId, adUnitCode, mediaTypes: finalizedMediatypes}) + }, []) + + if (!impressions.length) { + return + } + + const request = { + id: auctionId ?? generateUUID(), + impressions, + device, + site, + user: { + ids: {} + }, + regulations: { + gdpr: { + applies: gdprApplies, + consentString + } + }, + timeout: 3600, + options: { + useProfileApi + } + } + + if (readOnePlusXId) { + const fpId = getFpIdFromLocalStorage() + + if (fpId) { + request.user.ids['1plusX'] = fpId + } + } + + const syncData = { + gdpr: gdprApplies, + ad_unit_codes: impressions.map(({adUnitCode}) => adUnitCode).join(',') + } + + if (consentString) { + syncData.gdpr_consent = consentString + } + + return [ + { + method: 'GET', + url: SYNC_ENDPOINT, + data: syncData + }, + { + method: 'POST', + url: AUCTION_ENDPOINT, + data: request + } + ] + }, + interpretResponse(serverResponse, params) { + if (!isValidResponse(serverResponse)) return [] + + const {body: {bids, optimizations}} = serverResponse + + if (optimizations && isArray(optimizations)) { + const currentTimestamp = Date.now() + + const optimizationsToStore = optimizations.reduce((acc, optimization) => { + const {adUnitCode, isEnabled, ttl} = optimization + + return { + ...acc, + [adUnitCode]: {isEnabled, expiresAt: currentTimestamp + ttl} + } + }, getOptimizationsFromLocalStorage()) + + storageManager.setDataInLocalStorage(OPTIMIZATIONS_STORAGE_KEY, JSON.stringify(optimizationsToStore)) + } + + return bids.map((bid) => { + const { + impressionId, + price: {cpm, currency}, + dealId, + ad: { + creative: {id, mediaType, size: {width, height}, markup} + }, + ttl = 360 + } = bid + + const formattedBid = { + requestId: impressionId, + cpm, + currency, + dealId, + ttl, + netRevenue: true, + creativeId: id, + mediaType, + width, + height, + ad: markup, + adUrl: null + } + + if (mediaType === VIDEO) { + const impressionRequest = params.data.impressions.find(({id}) => id === impressionId) + + formattedBid.vastXml = markup + + if (impressionRequest) { + formattedBid.renderer = buildXandrOutstreamRenderer(impressionId, impressionRequest.adUnitCode) + } else { + logWarn(`Could not find adUnitCode matching the impressionId ${impressionId} to setup the renderer`) + } + } + + return formattedBid + }) + }, + getUserSyncs(syncOptions, serverResponses) { + if (serverResponses.length !== 2) { + return + } + + const [sync] = serverResponses + + return sync.body?.bidders?.reduce((acc, {type, url}) => { + const syncType = SYNC_TYPES[type] + + if (!syncType || !url) { + return acc + } + + return acc.concat({type: syncType, url}) + }, []) + } +} + +registerBidder(spec) + +export function getOptimizationsFromLocalStorage() { + try { + const storedOptimizations = storageManager.getDataFromLocalStorage(OPTIMIZATIONS_STORAGE_KEY) + + return storedOptimizations ? JSON.parse(storedOptimizations) : {} + } catch (err) { + return {} + } +} + +function getFpIdFromLocalStorage() { + try { + return storageManager.getDataFromLocalStorage('ope_fpid') + } catch (err) { + return null + } +} + +function isValidResponse(response) { + return isPlainObject(response) && + isPlainObject(response.body) && + isArray(response.body.bids) +} + +function buildXandrOutstreamRenderer(bidId, adUnitCode) { + const renderer = Renderer.install({ + id: bidId, + url: XANDR_OUTSTREAM_RENDERER_URL, + loaded: false, + adUnitCode, + targetId: adUnitCode + }); + + try { + renderer.setRender(xandrOutstreamRenderer); + } catch (err) { + logWarn('Prebid Error calling setRender on renderer', err); + } + + return renderer; +} + +function xandrOutstreamRenderer(bid) { + const {width, height, adUnitCode, vastXml} = bid + + bid.renderer.push(() => { + window.ANOutstreamVideo.renderAd({ + sizes: [width, height], + targetId: adUnitCode, + rendererOptions: { + showBigPlayButton: false, + showProgressBar: 'bar', + content: vastXml, + showVolume: false, + allowFullscreen: true, + skippable: false + } + }); + }); +} diff --git a/modules/mediaConsortiumBidAdapter.md b/modules/mediaConsortiumBidAdapter.md new file mode 100644 index 00000000000..b78627077cb --- /dev/null +++ b/modules/mediaConsortiumBidAdapter.md @@ -0,0 +1,79 @@ +# Media Consortium Bid adapter + +## Overview + +``` +- Module Name: Media Consortium Bidder Adapter +- Module Type: Media Consortium Bidder Adapter +- Maintainer: mediaconsortium-develop@bi.garage.co.jp +``` + +## Description + +Module that connects to Media Consortium demand sources and supports the following media types: `banner`, `video`. + +To get access to the full feature set of the adapter you'll need to allow localstorage usage in the `bidderSettings`. + +```javascript + pbjs.bidderSettings = { + mediaConsortium: { + storageAllowed: true + } + } +``` + +## Managing 1plusX profile API usage and FPID retrieval + +You can use the `setBidderConfig` function to enable or disable 1plusX profile API usage and fpid retrieval. + +If the keys found below are not defined, their values will default to `false`. + +```javascript + pbjs.setBidderConfig({ + bidders: ['mediaConsortium'], + config: { + // Controls the 1plusX profile API usage + useProfileApi: true, + // Controls the 1plusX fpid retrieval + readOnePlusXId: true + } + }); +``` + +## Test Parameters + +```javascript + var adUnits = [ + { + code: 'div-prebid-banner', + mediaTypes:{ + banner: { + sizes: [[300, 250]], + } + }, + bids:[ + { + bidder: 'mediaConsortium', + params: {} + } + ] + }, + { + code: 'div-prebid-video', + mediaTypes:{ + video: { + playerSize: [ + [300, 250] + ], + context: 'outstream' + } + }, + bids:[ + { + bidder: 'mediaConsortium', + params: {} + } + ] + } + ]; +``` diff --git a/test/spec/modules/mediaConsortiumBidAdapter_spec.js b/test/spec/modules/mediaConsortiumBidAdapter_spec.js new file mode 100644 index 00000000000..6a7ac6f5741 --- /dev/null +++ b/test/spec/modules/mediaConsortiumBidAdapter_spec.js @@ -0,0 +1,411 @@ +import { expect } from 'chai'; +import { spec, OPTIMIZATIONS_STORAGE_KEY, getOptimizationsFromLocalStorage } from 'modules/mediaConsortiumBidAdapter.js'; + +const BANNER_BID = { + adUnitCode: 'dfp_ban_atf', + bidId: '2f0d9715f60be8', + mediaTypes: { + banner: { + sizes: [ + [300, 250] + ] + } + } +} + +const VIDEO_BID = { + adUnitCode: 'video', + bidId: '2f0d9715f60be8', + mediaTypes: { + video: { + playerSize: [ + [300, 250] + ], + context: 'outstream' + } + } +} + +const VIDEO_BID_WITH_MISSING_CONTEXT = { + adUnitCode: 'video', + bidId: '2f0d9715f60be8', + mediaTypes: { + video: { + playerSize: [ + [300, 250] + ] + } + } +} + +const MULTI_MEDIATYPES_BID = { + adUnitCode: 'multi_type', + bidId: '2f0d9715f60be8', + mediaTypes: { + banner: { + sizes: [ + [300, 250] + ] + }, + video: { + playerSize: [ + [300, 250] + ], + context: 'outstream' + } + } +} + +const MULTI_MEDIATYPES_WITH_INVALID_VIDEO_CONTEXT = { + adUnitCode: 'multi_type', + bidId: '2f0d9715f60be8', + mediaTypes: { + banner: { + sizes: [ + [300, 250] + ] + }, + video: { + playerSize: [ + [300, 250] + ], + context: 'instream' + } + } +} + +describe('Media Consortium Bid Adapter', function () { + before(function () { + // The local storage variable is not cleaned in some other test so we need to do it ourselves here + localStorage.removeItem('ope_fpid') + }) + + beforeEach(function () { + $$PREBID_GLOBAL$$.bidderSettings = { + mediaConsortium: { + storageAllowed: true + } + } + }) + + afterEach(function () { + $$PREBID_GLOBAL$$.bidderSettings = {}; + }) + + describe('buildRequests', function () { + const bidderRequest = { + auctionId: '98bb5f61-4140-4ced-8b0e-65a33d792ab8', + ortb2: { + device: { + w: 1200, + h: 400, + dnt: 0 + }, + site: { + page: 'http://localhost.com', + domain: 'localhost.com' + } + } + }; + + it('should build a banner request', function () { + const builtSyncRequest = { + gdpr: false, + ad_unit_codes: BANNER_BID.adUnitCode + } + + const builtBidRequest = { + id: '98bb5f61-4140-4ced-8b0e-65a33d792ab8', + impressions: [{ + id: BANNER_BID.bidId, + adUnitCode: BANNER_BID.adUnitCode, + mediaTypes: BANNER_BID.mediaTypes + }], + device: { + w: 1200, + h: 400, + dnt: 0 + }, + site: { + page: 'http://localhost.com', + domain: 'localhost.com' + }, + user: { + ids: {} + }, + options: { + useProfileApi: false + }, + regulations: { + gdpr: { + applies: false, + consentString: undefined + } + }, + timeout: 3600 + } + + const bids = [BANNER_BID] + const [syncRequest, auctionRequest] = spec.buildRequests(bids, {...bidderRequest, bids}); + + expect(syncRequest.data).to.deep.equal(builtSyncRequest) + expect(auctionRequest.data).to.deep.equal(builtBidRequest) + }) + + it('should build a video request', function () { + const builtSyncRequest = { + gdpr: false, + ad_unit_codes: VIDEO_BID.adUnitCode + } + + const builtBidRequest = { + id: '98bb5f61-4140-4ced-8b0e-65a33d792ab8', + impressions: [{ + id: VIDEO_BID.bidId, + adUnitCode: VIDEO_BID.adUnitCode, + mediaTypes: VIDEO_BID.mediaTypes + }], + device: { + w: 1200, + h: 400, + dnt: 0 + }, + site: { + page: 'http://localhost.com', + domain: 'localhost.com' + }, + user: { + ids: {} + }, + options: { + useProfileApi: false + }, + regulations: { + gdpr: { + applies: false, + consentString: undefined + } + }, + timeout: 3600 + } + + const bids = [VIDEO_BID] + const [syncRequest, auctionRequest] = spec.buildRequests(bids, {...bidderRequest, bids}); + + expect(syncRequest.data).to.deep.equal(builtSyncRequest) + expect(auctionRequest.data).to.deep.equal(builtBidRequest) + }) + + it('should build a request with multiple mediatypes', function () { + const builtSyncRequest = { + gdpr: false, + ad_unit_codes: MULTI_MEDIATYPES_BID.adUnitCode + } + + const builtBidRequest = { + id: '98bb5f61-4140-4ced-8b0e-65a33d792ab8', + impressions: [{ + id: MULTI_MEDIATYPES_BID.bidId, + adUnitCode: MULTI_MEDIATYPES_BID.adUnitCode, + mediaTypes: MULTI_MEDIATYPES_BID.mediaTypes + }], + device: { + w: 1200, + h: 400, + dnt: 0 + }, + site: { + page: 'http://localhost.com', + domain: 'localhost.com' + }, + user: { + ids: {} + }, + options: { + useProfileApi: false + }, + regulations: { + gdpr: { + applies: false, + consentString: undefined + } + }, + timeout: 3600 + } + + const bids = [MULTI_MEDIATYPES_BID] + const [syncRequest, auctionRequest] = spec.buildRequests(bids, {...bidderRequest, bids}); + + expect(syncRequest.data).to.deep.equal(builtSyncRequest) + expect(auctionRequest.data).to.deep.equal(builtBidRequest) + }) + + it('should not build a request if optimizations are there for the adunit code', function () { + const bids = [BANNER_BID] + const optimizations = { + [bids[0].adUnitCode]: {isEnabled: false, expiresAt: Date.now() + 600000} + } + + localStorage.setItem(OPTIMIZATIONS_STORAGE_KEY, JSON.stringify(optimizations)) + + const requests = spec.buildRequests(bids, {...bidderRequest, bids}); + + localStorage.removeItem(OPTIMIZATIONS_STORAGE_KEY) + + expect(requests).to.be.undefined + }) + + it('should exclude video requests where context is missing or not equal to outstream', function () { + const builtSyncRequest = { + gdpr: false, + ad_unit_codes: MULTI_MEDIATYPES_WITH_INVALID_VIDEO_CONTEXT.adUnitCode + } + + const builtBidRequest = { + id: '98bb5f61-4140-4ced-8b0e-65a33d792ab8', + impressions: [{ + id: MULTI_MEDIATYPES_WITH_INVALID_VIDEO_CONTEXT.bidId, + adUnitCode: MULTI_MEDIATYPES_WITH_INVALID_VIDEO_CONTEXT.adUnitCode, + mediaTypes: {banner: MULTI_MEDIATYPES_WITH_INVALID_VIDEO_CONTEXT.mediaTypes.banner} + }], + device: { + w: 1200, + h: 400, + dnt: 0 + }, + site: { + page: 'http://localhost.com', + domain: 'localhost.com' + }, + user: { + ids: {} + }, + options: { + useProfileApi: false + }, + regulations: { + gdpr: { + applies: false, + consentString: undefined + } + }, + timeout: 3600 + } + + const invalidVideoBids = [VIDEO_BID_WITH_MISSING_CONTEXT] + const multiMediatypesBidWithInvalidVideo = [MULTI_MEDIATYPES_WITH_INVALID_VIDEO_CONTEXT] + + expect(spec.buildRequests(invalidVideoBids, {...bidderRequest, bids: invalidVideoBids})).to.be.undefined + + const [syncRequest, auctionRequest] = spec.buildRequests(multiMediatypesBidWithInvalidVideo, {...bidderRequest, bids: multiMediatypesBidWithInvalidVideo}) + + expect(syncRequest.data).to.deep.equal(builtSyncRequest) + expect(auctionRequest.data).to.deep.equal(builtBidRequest) + }) + }) + + describe('interpretResponse', function () { + it('should return an empty array if the response is invalid', function () { + expect(spec.interpretResponse({body: 'INVALID_BODY'}, {})).to.deep.equal([]); + }) + + it('should return a formatted bid', function () { + const serverResponse = { + body: { + id: 'requestId', + bids: [{ + impressionId: '2f0d9715f60be8', + price: { + cpm: 1, + currency: 'JPY' + }, + dealId: 'TEST_DEAL_ID', + ad: { + creative: { + id: 'CREATIVE_ID', + mediaType: 'banner', + size: {width: 320, height: 250}, + markup: '
1
' + } + }, + ttl: 3600 + }], + optimizations: [ + { + adUnitCode: 'test_ad_unit_code', + isEnabled: false, + ttl: 12000 + }, + { + adUnitCode: 'test_ad_unit_code_2', + isEnabled: true, + ttl: 12000 + } + ] + } + } + + const formattedBid = { + requestId: '2f0d9715f60be8', + cpm: 1, + currency: 'JPY', + dealId: 'TEST_DEAL_ID', + ttl: 3600, + netRevenue: true, + creativeId: 'CREATIVE_ID', + mediaType: 'banner', + width: 320, + height: 250, + ad: '
1
', + adUrl: null + } + + const formattedResponse = spec.interpretResponse(serverResponse, {}) + const storedOptimizations = getOptimizationsFromLocalStorage() + + localStorage.removeItem(OPTIMIZATIONS_STORAGE_KEY) + + expect(formattedResponse).to.deep.equal([formattedBid]); + + expect(storedOptimizations['test_ad_unit_code']).to.exist + expect(storedOptimizations['test_ad_unit_code'].isEnabled).to.equal(false) + expect(storedOptimizations['test_ad_unit_code'].expiresAt).to.be.a('number') + + expect(storedOptimizations['test_ad_unit_code_2']).to.exist + expect(storedOptimizations['test_ad_unit_code_2'].isEnabled).to.equal(true) + expect(storedOptimizations['test_ad_unit_code_2'].expiresAt).to.be.a('number') + }) + }); + + describe('getUserSyncs', function () { + it('should return an empty response if the response is invalid or missing data', function () { + expect(spec.getUserSyncs(null, [{body: 'INVALID_BODY'}])).to.be.undefined; + expect(spec.getUserSyncs(null, [{body: 'INVALID_BODY'}, {body: 'INVALID_BODY'}])).to.be.undefined; + }) + + it('should return an array of user syncs', function () { + const serverResponses = [ + { + body: { + bidders: [ + {type: 'image', url: 'https://test-url.com'}, + {type: 'redirect', url: 'https://test-url.com'}, + {type: 'iframe', url: 'https://test-url.com'} + ] + } + }, + { + body: 'BID-RESPONSE-DATA' + } + ] + + const formattedUserSyncs = [ + {type: 'image', url: 'https://test-url.com'}, + {type: 'image', url: 'https://test-url.com'}, + {type: 'iframe', url: 'https://test-url.com'} + ] + + expect(spec.getUserSyncs(null, serverResponses)).to.deep.equal(formattedUserSyncs); + }) + }); +});