diff --git a/consumable/CHANGES.md b/consumable/CHANGES.md new file mode 100644 index 00000000..cc7c71fc --- /dev/null +++ b/consumable/CHANGES.md @@ -0,0 +1,4 @@ +# 2.1.0 +- Add support for CCPA and GDPR consent information. +- Add missing parameters to bid request. +- Improve documentation. diff --git a/consumable/DOCUMENTATION.md b/consumable/DOCUMENTATION.md new file mode 100644 index 00000000..f903725f --- /dev/null +++ b/consumable/DOCUMENTATION.md @@ -0,0 +1,152 @@ +# Consumable +## General Compatibility +|Feature| | +|---|---| +| Consent | Yes | +| Native Ad Support | Yes | +| SafeFrame Support | Yes | +| PMP Support | No | + +## Browser Compatibility +| Browser | | +|--- |---| +| Chrome | Yes | +| Edge | Yes | +| Firefox | Yes | +| Internet Explorer 9 | No | +| Internet Explorer 10 | Yes | +| Internet Explorer 11 | Yes | +| Safari | Yes | +| Mobile Chrome | Yes | +| Mobile Safari | Yes | +| UC Browser | No | +| Samsung Internet | No | +| Opera | Yes | + +## Adapter Information +| Info | | +|---|---| +| Partner Id | ConsumableHtb | +| Ad Server Responds in (Cents, Dollars, etc) | Dollars | +| Bid Type (Gross / Net) | Net | +| GAM Key (Open Market) | ix_cnsm_id | +| GAM Key (Private Market) | ix_cnsm_dealid | +| Ad Server URLs | serverbid.com | +| Slot Mapping Style (Size / Multiple Sizes / Slot) | Size | +| Request Architecture (MRA / SRA) | SRA | + +## Currencies Supported +USD $ + +## Bid Request Information +### Parameters +| Key | Required | Type | Description | +|---|---|---|---| +| `placements` | Yes | `Array\` | List of ad placements (slots) to bid on | +| `time` | Yes | `Integer` | Time and date of the request as milliseconds elapsed since January 1, 1970 | +| `user` | Yes | `Object` | Currently always an empty object | +| `url` | Yes | `String` | URL of the current page | +| `referrer` | No | `String` | URL of the referrer | +| `enableBotFiltering` | Yes | `Boolean` | Always `true` | +| `includePricingData` | Yes | `Boolean` | Always `true` | +| `parallel` | Yes | `Boolean` | Always `true` | + +### Placement +| Key | Required | Type | Description | +| --- | --- | --- | --- | +| `divName` | Yes | `String` | Name of the slot to bid on | +| `adTypes` | Yes | `Array\` | List of IDs that determines possible ad sizes | +| `siteId` | Yes | `String` | Fixed identifier supplied by Consumable | +| `networkId` | Yes | `String` | Fixed identifier supplied by Consumable | +| `zoneIds` | No | `Array\` | List of fixed identifiers supplied by Consumable +| `unitId` | No | `String` | Fixed identifier supplied by Consumable | +| `unitName` | No | `String` | Fixed identifier supplied by Consumable | + +### Example +```json +{ + "placements":[ + { + "divName": "28cbf8f87b7d5", + "adTypes": [5], + "siteId": "1035514", + "networkId": "9969", + "zoneIds": [188825], + "unitId": "4508", + "unitName": "cnsmbl-audio-320x50-slider" + } + ], + "time": 1538599207715, + "user": {}, + "url": "http://example.com/page/path", + "referrer": "http://example.com/another/page", + "enableBotFiltering": true, + "includePricingData": true, + "parallel": true +} +``` + + +## Bid Response Information +### Bid Example +```json +{ + "decisions":{ + "2e53ecd4a29b06":{ + "adId":-7176956978374674467, + "impressionUrl":"https://e.serverbid.com/i/?i=ARAAAAAAAAAAcP...", + "contents":[ + { + "body":"", + "data":null, + "type":"rtb" + } + ], + "height":250, + "width":300, + "pricing":{ + "clearPrice":0.3075, + "eCPM":0.0, + "price":0.0, + "rateType":2, + "revenue":0.0 + } + } + } +} +``` + +### Pass Example +```json +{ + "user": { + "key": "ad39231daeb043f2a9610414f08394b5" + }, + "decisions": { + "2e53ecd4a29b06": null + } +} +``` + +## Configuration Information +### Configuration Keys +| Key | Required | Type | Description | +|---|---|---|---| +| `networkId` | Yes | `String` | Network ID (supplied by Consumable) | +| `siteId` | Yes | `String` | Site ID (supplied by Consumable) | +| `zoneIds` | No | `Array\` | Zone IDs (supplied by Consumable) | +| `unitId` | Yes | `String` | Unit ID (supplied by Consumable) | +| `unitName` | Yes | `String` | Unit Name (supplied by Consumable) | +| `sizes` | Yes | `Array\<\[Integer, Integer\]\>` | Possible sizes of the ad in pixels (`\[width, height\]`) | + +### Example +``` +{ + "networkId": "9969", + "siteId": "1029010", + "zoneIds": [187327], + "unitId": "4508", + "unitName": "cnsmbl-audio-320x50-slider", + "sizes": [[320, 50]] +} +``` \ No newline at end of file diff --git a/consumable/consumable-htb-exports.js b/consumable/consumable-htb-exports.js new file mode 100644 index 00000000..40e364ee --- /dev/null +++ b/consumable/consumable-htb-exports.js @@ -0,0 +1,5 @@ +//? if (FEATURES.GPT_LINE_ITEMS) { +shellInterface.ConsumableHtb = { + render: SpaceCamp.services.RenderService.renderDfpAd.bind(null, 'ConsumableHtb') +}; +//? } diff --git a/consumable/consumable-htb-system-tests.js b/consumable/consumable-htb-system-tests.js new file mode 100644 index 00000000..1b1bfa96 --- /dev/null +++ b/consumable/consumable-htb-system-tests.js @@ -0,0 +1,148 @@ +'use strict'; + +function getPartnerId() { + return 'ConsumableHtb'; +} + +function getStatsId() { + return 'CNSM'; +} + +function getBidRequestRegex() { + return { + method: 'POST', + urlRegex: /^https?:\/\/e\.serverbid\.com\/api\// + }; +} + +function getCallbackType() { + return 'NONE'; +} + +function getArchitecture() { + return 'SRA'; +} + +function getConfig() { + return { + xSlots: { + 1: { + networkId: '9969', + siteId: '1029010', + zoneIds: [187327], + unitId: '4508', + unitName: 'cnsmbl-audio-320x50-slider', + sizes: [[320, 50]] + }, + 2: { + networkId: '9969', + siteId: '1029010', + zoneIds: [187327], + unitId: '4508', + unitName: 'cnsmbl-audio-320x50-slider', + sizes: [[320, 50]] + } + } + }; +} + +function validateBidRequest(request) { + expect(request.host).toBe('e.serverbid.com'); + expect(request.pathname).toBe('/api/v2'); + expect(request.query).toEqual({}); + + var data = JSON.parse(request.body); + expect(data.gdpr).toBeDefined(); + expect(data.gdpr.applies).toBeDefined(); + expect(data.gdpr.consent).toBeDefined(); +} + +function getValidResponse(request, creative) { + return JSON.stringify({ + user: { + key: 'ad39231daeb043f2a9610414f08394b5' + }, + decisions: { + 1: { + adId: 1234, + creativeId: 2345, + flightId: 3456, + campaignId: 4567, + clickUrl: 'http://example.org/click', + impressionUrl: 'http://example.org/impression', + contents: [ + { + type: 'html', + body: creative + } + ], + height: 50, + width: 320, + events: [], + pricing: { + price: 0, + clearPrice: 2, + revenue: 0.002, + rateType: 2, + eCPM: 0 + } + }, + 2: { + adId: 1234, + creativeId: 2345, + flightId: 3456, + campaignId: 4567, + clickUrl: 'http://example.org/click', + impressionUrl: 'http://example.org/impression', + contents: [ + { + type: 'html', + body: creative + } + ], + height: 50, + width: 320, + events: [], + pricing: { + price: 0, + clearPrice: 2, + revenue: 0.002, + rateType: 2, + eCPM: 0 + } + } + } + }); +} + +function validateTargeting(targetingMap) { + expect(targetingMap).toEqual(jasmine.objectContaining({ + ix_cnsm_id: jasmine.arrayWithExactContents([jasmine.any(String), jasmine.any(String)]), + ix_cnsm_cpm: jasmine.arrayWithExactContents(['320x50_200', '320x50_200']) + })); +} + +function getPassResponse() { + return JSON.stringify({ + user: { + key: 'ad39231daeb043f2a9610414f08394b5' + }, + decisions: { + 1: null, + 2: null + } + }); +} + +module.exports = { + getPartnerId: getPartnerId, + getStatsId: getStatsId, + getBidRequestRegex: getBidRequestRegex, + getCallbackType: getCallbackType, + getArchitecture: getArchitecture, + getConfig: getConfig, + validateBidRequest: validateBidRequest, + getValidResponse: getValidResponse, + validateTargeting: validateTargeting, + getPassResponse: getPassResponse +}; diff --git a/consumable/consumable-htb-validator.js b/consumable/consumable-htb-validator.js new file mode 100644 index 00000000..00242cd8 --- /dev/null +++ b/consumable/consumable-htb-validator.js @@ -0,0 +1,80 @@ +'use strict'; + +//////////////////////////////////////////////////////////////////////////////// +// Dependencies //////////////////////////////////////////////////////////////// +//////////////////////////////////////////////////////////////////////////////// + +var Inspector = require('../../../libs/external/schema-inspector.js'); + +//////////////////////////////////////////////////////////////////////////////// +// Main //////////////////////////////////////////////////////////////////////// +//////////////////////////////////////////////////////////////////////////////// + +/* ============================================================================= + * STEP 0 | Config Validation + * ----------------------------------------------------------------------------- + * This file contains the necessary validation for the partner configuration. + * This validation will be performed on the partner specific configuration object + * that is passed into the wrapper. The wrapper uses an outside library called + * schema-insepctor to perform the validation. Information about it can be found here: + * https://atinux.fr/schema-inspector/. + */ +function partnerValidator(configs) { + var result = Inspector.validate({ + type: 'object', + properties: { + xSlots: { + type: 'object', + properties: { + '*': { + type: 'object', + properties: { + networkId: { + type: 'string', + minLength: 1 + }, + siteId: { + type: 'string', + minLength: 1 + }, + zoneIds: { + type: 'array', + optional: true, + items: { + type: 'number' + } + }, + unitId: { + type: 'string', + minLength: 1 + }, + unitName: { + type: 'string', + minLength: 1 + }, + sizes: { + type: 'array', + minLength: 1, + items: { + type: 'array', + exactLength: 2, + items: { + type: 'number' + } + } + } + } + } + } + } + } + }, configs); + + if (!result.valid) { + return result.format(); + } + + return null; +} + +module.exports = partnerValidator; diff --git a/consumable/consumable-htb.js b/consumable/consumable-htb.js new file mode 100644 index 00000000..d7e7d86b --- /dev/null +++ b/consumable/consumable-htb.js @@ -0,0 +1,537 @@ +'use strict'; + +//////////////////////////////////////////////////////////////////////////////// +// Dependencies //////////////////////////////////////////////////////////////// +//////////////////////////////////////////////////////////////////////////////// + +var Browser = require('browser.js'); +var Classify = require('classify.js'); +var Constants = require('constants.js'); +var Partner = require('partner.js'); +var Size = require('size.js'); +var SpaceCamp = require('space-camp.js'); +var System = require('system.js'); +var Network = require('network.js'); +var Utilities = require('utilities.js'); + +var RenderService; +var ComplianceService; + +//? if (DEBUG) { +var ConfigValidators = require('config-validators.js'); +var PartnerSpecificValidator = require('consumable-htb-validator.js'); +var Scribe = require('scribe.js'); +var Whoopsie = require('whoopsie.js'); +//? } + +//////////////////////////////////////////////////////////////////////////////// +// Main //////////////////////////////////////////////////////////////////////// +//////////////////////////////////////////////////////////////////////////////// + +/** + * Partner module template + * + * @class + */ +function ConsumableHtb(configs) { + /* ===================================== + * Data + * ---------------------------------- */ + + /* Private + * ---------------------------------- */ + + /** + * Reference to the partner base class. + * + * @private {object} + */ + var __baseClass; + + /** + * Profile for this partner. + * + * @private {object} + */ + var __profile; + + /* ===================================== + * Functions + * ---------------------------------- */ + + /* Utilities + * ---------------------------------- */ + + var adTypesBySize = { + '120x90': 1, + '468x60': 3, + '728x90': 4, + '300x250': 5, + '160x600': 6, + '120x600': 7, + '300x100': 8, + '180x150': 9, + '336x280': 10, + '240x400': 11, + '234x60': 12, + '88x31': 13, + '120x60': 14, + '120x240': 15, + '125x125': 16, + '220x250': 17, + '250x250': 18, + '250x90': 19, + '0x0': 20, + '200x90': 21, + '300x50': 22, + '320x50': 23, + '320x480': 24, + '185x185': 25, + '620x45': 26, + '300x125': 27, + '800x250': 28, + '300x600': 43, + '970x90': 77, + '970x250': 123, + '970x66': 286, + '320x250': 331, + '700x500': 374, + '486x60': 429, + '300x1050': 934, + '320x100': 1578, + '728x250': 2730, + '970x280': 3230, + '320x267': 3301 + }; + + function sizeToAdType(size) { + return adTypesBySize[Size.arrayToString(size)]; + } + + /** + * Generates the request URL and query data to the endpoint for the xSlots + * in the given returnParcels. + * + * @param {object[]} returnParcels + * + * @return {object} + */ + function __generateRequestObj(returnParcels) { + /* ============================================================================= + * STEP 2 | Generate Request URL + * ----------------------------------------------------------------------------- + * + * Generate the URL to request demand from the partner endpoint using the provided + * returnParcels. The returnParcels is an array of objects each object containing + * an .xSlotRef which is a reference to the xSlot object from the partner configuration. + * Use this to retrieve the placements/xSlots you need to request for. + * + * If your partner is MRA, returnParcels will be an array of length one. If your + * partner is SRA, it will contain any number of entities. In any event, the full + * contents of the array should be able to fit into a single request and the + * return value of this function should similarly represent a single request to the + * endpoint. + * + * Return an object containing: + * queryUrl: the url for the request + * data: the query object containing a map of the query string paramaters + * + * callbackId: + * + * arbitrary id to match the request with the response in the callback function. If + * your endpoint supports passing in an arbitrary ID and returning it as part of the response + * please use the callbackType: Partner.CallbackTypes.ID and fill out the adResponseCallback. + * Also please provide this adResponseCallback to your bid request here so that the JSONP + * response calls it once it has completed. + * + * If your endpoint does not support passing in an ID, simply use + * Partner.CallbackTypes.CALLBACK_NAME and the wrapper will take care of handling request + * matching by generating unique callbacks for each request using the callbackId. + * + * If your endpoint is ajax only, please set the appropriate values in your profile for this, + * i.e. Partner.CallbackTypes.NONE and Partner.Requesttypes.AJAX. You also do not need to provide + * a callbackId in this case because there is no callback. + * + * The return object should look something like this: + * { + * url: 'http://bidserver.com/api/bids' // base request url for a GET/POST request + * data: { // query string object that will be attached to the base url + * slots: [ + * { + * placementId: 54321, + * sizes: [[300, 250]] + * },{ + * placementId: 12345, + * sizes: [[300, 600]] + * },{ + * placementId: 654321, + * sizes: [[728, 90]] + * } + * ], + * site: 'http://google.com' + * }, + * callbackId: '_23sd2ij4i1' //unique id used for pairing requests and responses + * } + */ + + var callbackId = System.generateUniqueId(); + + /* ------------------------ Get consent information ------------------------- + * If you want to implement GDPR consent in your adapter, use the function + * ComplianceService.gdpr.getConsent() which will return an object. + * + * Here is what the values in that object mean: + * - applies: the boolean value indicating if the request is subject to + * GDPR regulations + * - consentString: the consent string developed by GDPR Consent Working + * Group under the auspices of IAB Europe + * + * The return object should look something like this: + * { + * applies: true, + * consentString: "BOQ7WlgOQ7WlgABABwAAABJOACgACAAQABA" + * } + * + * You can also determine whether or not the publisher has enabled privacy + * features in their wrapper by querying ComplianceService.isPrivacyEnabled(). + * + * This function will return a boolean, which indicates whether the wrapper's + * privacy features are on (true) or off (false). If they are off, the values + * returned from gdpr.getConsent() are safe defaults and no attempt has been + * made by the wrapper to contact a Consent Management Platform. + */ + + var data = { + placements: returnParcels.map(function (parcel) { + return { + networkId: parcel.xSlotRef.networkId, + siteId: parcel.xSlotRef.siteId, + zoneIds: parcel.xSlotRef.zoneIds, + unitId: parcel.xSlotRef.unitId, + unitName: parcel.xSlotRef.unitName, + divName: parcel.xSlotName, + adTypes: parcel.xSlotRef.sizes + .map(sizeToAdType) + .filter(Utilities.isNumber) + }; + }), + time: System.now(), + user: {}, + url: Browser.getPageUrl(), + referrer: Browser.getReferrer(), + enableBotFiltering: true, + includePricingData: true, + parallel: true + }; + + var uspConsent = ComplianceService.usp && ComplianceService.usp.getConsent(); + if (uspConsent && uspConsent.uspString) { + data.ccpa = uspConsent.uspString; + } + + var gdprConsent = ComplianceService.gdpr && ComplianceService.gdpr.getConsent(); + if (gdprConsent) { + data.gdpr = { + applies: gdprConsent.applies, + consent: gdprConsent.consentString + }; + } + + return { + url: Browser.getProtocol() + '//e.serverbid.com/api/v2', + data: data, + callbackId: callbackId, + networkParamOverrides: { + method: 'POST', + contentType: 'application/json' + } + }; + } + + /* Helpers + * ---------------------------------- */ + + /* ============================================================================= + * STEP 5 | Rendering Pixel + * ----------------------------------------------------------------------------- + * + */ + + /** + * This function will render the pixel given. + * @param {string} pixelUrl Tracking pixel img url. + */ + function __renderPixel(pixelUrl) { + if (pixelUrl) { + Network.img({ + url: pixelUrl, + method: 'GET' + }); + } + } + + /** + * Parses and extracts demand from adResponse according to the adapter and then attaches it + * to the corresponding bid's returnParcel in the correct format using targeting keys. + * + * @param {string} sessionId The sessionId, used for stats and other events. + * + * @param {any} adResponse This is the bid response as returned from the bid request, that was either + * passed to a JSONP callback or simply sent back via AJAX. + * + * @param {object[]} returnParcels The array of original parcels, SAME array that was passed to + * generateRequestObj to signal which slots need demand. In this funciton, the demand needs to be + * attached to each one of the objects for which the demand was originally requested for. + */ + function __parseResponse(sessionId, adResponse, returnParcels) { + /* ============================================================================= + * STEP 4 | Parse & store demand response + * ----------------------------------------------------------------------------- + * + * Fill the below variables with information about the bid from the partner, using + * the adResponse variable that contains your module adResponse. + */ + + /* This an array of all the bids in your response that will be iterated over below. Each of + * these will be mapped back to a returnParcel object using some criteria explained below. + * The following variables will also be parsed and attached to that returnParcel object as + * returned demand. + * + * Use the adResponse variable to extract your bid information and insert it into the + * bids array. Each element in the bids array should represent a single bid and should + * match up to a single element from the returnParcel array. + * + */ + + for (var j = 0; j < returnParcels.length; j++) { + var curReturnParcel = returnParcels[j]; + + var headerStatsInfo = {}; + var htSlotId = curReturnParcel.htSlot.getId(); + headerStatsInfo[htSlotId] = {}; + headerStatsInfo[htSlotId][curReturnParcel.requestId] = [curReturnParcel.xSlotName]; + + var decision = adResponse.decisions && adResponse.decisions[curReturnParcel.xSlotName]; + + if (!decision) { + /* No matching bid found so its a pass */ + if (__profile.enabledAnalytics.requestTime) { + __baseClass._emitStatsEvent(sessionId, 'hs_slot_pass', headerStatsInfo); + } + curReturnParcel.pass = true; + + continue; + } + + /* The bid price for the given slot */ + var bidPrice = (decision.pricing && Number(decision.pricing.clearPrice)) || 0; + + /* The size of the given slot */ + var bidSize = [decision.width, decision.height]; + + /* The creative/adm for the given slot that will be rendered if is the winner. + * Please make sure the URL is decoded and ready to be document.written. + */ + var wrappedCreative = (decision.contents && decision.contents[0] && decision.contents[0].body) || ''; + var cb = System.now(); + var bidCreative = '' + + '
' + + wrappedCreative + + '
' + + '
' + + ''; + + /* The dealId if applicable for this slot. */ + var bidDealId = ''; + + /* Explicitly pass */ + var bidIsPass = bidPrice <= 0; + + /* OPTIONAL: tracking pixel url to be fired AFTER rendering a winning creative. + * If firing a tracking pixel is not required or the pixel url is part of the adm, + * leave empty; + */ + var pixelUrl = decision.impressionUrl || ''; + + /* --------------------------------------------------------------------------------------- */ + + if (bidIsPass) { + //? if (DEBUG) { + Scribe.info(__profile.partnerId + ' returned pass for { id: ' + adResponse.id + ' }.'); + //? } + if (__profile.enabledAnalytics.requestTime) { + __baseClass._emitStatsEvent(sessionId, 'hs_slot_pass', headerStatsInfo); + } + curReturnParcel.pass = true; + + continue; + } + + if (__profile.enabledAnalytics.requestTime) { + __baseClass._emitStatsEvent(sessionId, 'hs_slot_bid', headerStatsInfo); + } + + curReturnParcel.size = bidSize; + curReturnParcel.targetingType = 'slot'; + curReturnParcel.targeting = {}; + + var targetingCpm = ''; + + //? if (FEATURES.GPT_LINE_ITEMS) { + targetingCpm = __baseClass._bidTransformers.targeting.apply(bidPrice); + var sizeKey = Size.arrayToString(curReturnParcel.size); + + if (bidDealId) { + curReturnParcel.targeting[__baseClass._configs.targetingKeys.pmid] = [sizeKey + '_' + bidDealId]; + curReturnParcel.targeting[__baseClass._configs.targetingKeys.pm] = [sizeKey + '_' + targetingCpm]; + } else { + curReturnParcel.targeting[__baseClass._configs.targetingKeys.om] = [sizeKey + '_' + targetingCpm]; + } + curReturnParcel.targeting[__baseClass._configs.targetingKeys.id] = [curReturnParcel.requestId]; + //? } + + //? if (FEATURES.RETURN_CREATIVE) { + curReturnParcel.adm = bidCreative; + if (pixelUrl) { + curReturnParcel.winNotice = __renderPixel.bind(null, pixelUrl); + } + //? } + + //? if (FEATURES.RETURN_PRICE) { + curReturnParcel.price = Number(__baseClass._bidTransformers.price.apply(bidPrice)); + //? } + + var expiry = 0; + if (__profile.features.demandExpiry.enabled) { + expiry = __profile.features.demandExpiry.value + System.now(); + } + + var pubKitAdId = RenderService.registerAd({ + sessionId: sessionId, + partnerId: __profile.partnerId, + adm: bidCreative, + requestId: curReturnParcel.requestId, + size: curReturnParcel.size, + price: targetingCpm, + dealId: bidDealId || null, + timeOfExpiry: expiry, + auxFn: __renderPixel, + auxArgs: [pixelUrl] + }); + + //? if (FEATURES.INTERNAL_RENDER) { + curReturnParcel.targeting.pubKitAdId = pubKitAdId; + //? } + } + } + + /* ===================================== + * Constructors + * ---------------------------------- */ + + (function __constructor() { + RenderService = SpaceCamp.services.RenderService; + ComplianceService = SpaceCamp.services.ComplianceService; + + /* ============================================================================= + * STEP 1 | Partner Configuration + * ----------------------------------------------------------------------------- + * + * Please fill out the below partner profile according to the steps in the README doc. + */ + + /* ---------- Please fill out this partner profile according to your module ------------ */ + __profile = { + partnerId: 'ConsumableHtb', + namespace: 'ConsumableHtb', + statsId: 'CNSM', + version: '2.1.0', + targetingType: 'slot', + enabledAnalytics: { + requestTime: true + }, + features: { + demandExpiry: { + enabled: false, + value: 0 + }, + rateLimiting: { + enabled: false, + value: 0 + } + }, + + /* Targeting keys for demand, should follow format ix_{statsId}_id */ + targetingKeys: { + id: 'ix_cnsm_id', + om: 'ix_cnsm_cpm', + pm: 'ix_cnsm_cpm', + pmid: 'ix_cnsm_dealid' + }, + + bidUnitInCents: 100, + lineItemType: Constants.LineItemTypes.ID_AND_SIZE, + callbackType: Partner.CallbackTypes.NONE, + architecture: Partner.Architectures.SRA, + requestType: Partner.RequestTypes.AJAX + }; + + /* --------------------------------------------------------------------------------------- */ + + //? if (DEBUG) { + var results = ConfigValidators.partnerBaseConfig(configs) || PartnerSpecificValidator(configs); + + if (results) { + throw Whoopsie('INVALID_CONFIG', results); + } + //? } + + __baseClass = Partner(__profile, configs, null, { + parseResponse: __parseResponse, + generateRequestObj: __generateRequestObj + }); + })(); + + /* ===================================== + * Public Interface + * ---------------------------------- */ + + var derivedClass = { + /* Class Information + * ---------------------------------- */ + + //? if (DEBUG) { + __type__: 'ConsumableHtb', + //? } + + //? if (TEST) { + __baseClass: __baseClass, + //? } + + /* Data + * ---------------------------------- */ + + //? if (TEST) { + profile: __profile, + //? } + + /* Functions + * ---------------------------------- */ + + //? if (TEST) { + parseResponse: __parseResponse, + generateRequestObj: __generateRequestObj + //? } + }; + + return Classify.derive(__baseClass, derivedClass); +} + +//////////////////////////////////////////////////////////////////////////////// +// Exports ///////////////////////////////////////////////////////////////////// +//////////////////////////////////////////////////////////////////////////////// + +module.exports = ConsumableHtb; diff --git a/conversant/CHANGES.md b/conversant/CHANGES.md new file mode 100644 index 00000000..07da0b81 --- /dev/null +++ b/conversant/CHANGES.md @@ -0,0 +1,3 @@ +# 2.3.0 +- Added USP (CCPA) support to bidder +- Use impid for matching requests \ No newline at end of file diff --git a/conversant/DOCUMENTATION.md b/conversant/DOCUMENTATION.md new file mode 100644 index 00000000..2c2a0f3b --- /dev/null +++ b/conversant/DOCUMENTATION.md @@ -0,0 +1,79 @@ +# conversant +## General Compatibility +|Feature| | +|---|---| +| Consent | Yes | +| Native Ad Support | No | +| SafeFrame Support | Yes | +| PMP Support | No | + +## Browser Compatibility +| Browser | | +|--- |---| +| Chrome | Yes | +| Edge | Yes | +| Firefox | Yes | +| Internet Explorer 9 | | +| Internet Explorer 10 | Yes | +| Internet Explorer 11 | Yes | +| Safari | Yes | +| Mobile Chrome | Yes | +| Mobile Safari | Yes | +| UC Browser | | +| Samsung Internet | Yes | +| Opera | Yes | + +## Adapter Information +| Info | | +|---|---| +| Partner Id | ConversantHtb | +| Ad Server Responds in (Cents, Dollars, etc) | Dollars | +| Bid Type (Gross / Net) | Net | +| GAM Key (Open Market) | ix_conv_cpm | +| GAM Key (Private Market) | ix_conv_cpm | +| Ad Server URLs | web.hb.ad.cpe.dotomi.com | +| Slot Mapping Style (Size / Multiple Sizes / Slot) | Slot | +| Request Architecture (MRA / SRA) | SRA | + +## Currencies Supported + USD +## Bid Request Information +### Parameters +| Key | Required | Type | Description | +|---|---|---|---| +| | | | | + +### Example +```javascript + +``` + +## Bid Response Information +### Bid Example +```javascript + +``` +### Pass Example +```javascript + +``` + +## Configuration Information +### Configuration Keys +| Key | Required | Type | Description | +|---|---|---|---| +| siteId | Yes | String | Conversant site id | +| sizes | Yes | Array of 2 element arrays | List of ad sizes | +| placementId | No | String | placement id | +| bidfloor | No | Number | Optional bid floor | +| position | No | Number | Ad position on screen | +### Example +```javascript + +``` + +## Test Configuration +(Test configuration or methodology that can be used to retrieve & render a test creative from conversant's platform) +```javascript + +``` diff --git a/conversant/conversant-htb-exports.js b/conversant/conversant-htb-exports.js new file mode 100644 index 00000000..661cf4df --- /dev/null +++ b/conversant/conversant-htb-exports.js @@ -0,0 +1,13 @@ +/** + * This file contains any necessary functions that need to be exposed to the outside world. + * Things like (render functions) will be exposed by adding them to the shellInterface variable, under the partners + * profile name. This function will then be accessible through the window.headertag.ConversantHtb object. + * If necessary for backwards compatibility with old creatives, you can also add things directly to the + * window namespace here, but this is discouraged if it's not strictly needed. + */ + +//? if(FEATURES.GPT_LINE_ITEMS) { +shellInterface.ConversantHtb = { + render: SpaceCamp.services.RenderService.renderDfpAd.bind(null, 'ConversantHtb') +}; +//? } diff --git a/conversant/conversant-htb-system-tests.js b/conversant/conversant-htb-system-tests.js new file mode 100644 index 00000000..010e0733 --- /dev/null +++ b/conversant/conversant-htb-system-tests.js @@ -0,0 +1,166 @@ +'use strict'; + +function getPartnerId() { + return 'ConversantHtb'; +} + +function getStatsId() { + return 'CONV'; +} + +function getCallbackType() { + return 'NONE'; +} + +function getArchitecture() { + return 'SRA'; +} + +function getBidRequestRegex() { + return { + method: 'POST', + urlRegex: /web\.hb\.ad\.cpe\.dotomi\.com\/s2s\/header\/24/ + }; +} + +function getConfig() { + return { + siteId: '108060', + xSlots: { + 1: { + placementId: '54321', + sizes: [[300, 250], [180, 150]] + }, + 2: { + placementId: '12345', + sizes: [[120, 600]], + position: 1, + bidfloor: 0.01 + } + } + }; +} + +function validateBidRequest(request) { + expect(request.host).toEqual('web.hb.ad.cpe.dotomi.com'); + + var config = getConfig(); + var slotKeys = Object.keys(config.xSlots); + expect(request.body).toBeDefined(); + + var body = JSON.parse(request.body); + expect(body.id).toBeDefined(); + + slotKeys.forEach(function (value, idx) { + var slot = config.xSlots[value]; + var imp = body.imp[idx]; + slot.sizes.forEach(function (arr, arrIdx) { + expect(imp.banner.format[arrIdx].w).toEqual(arr[0]); + expect(imp.banner.format[arrIdx].h).toEqual(arr[1]); + }); + expect(imp.displaymanager).toEqual('40834-index-client'); + expect(imp.tagid).toEqual(slot.placementId); + }); + + expect(body.imp[1].banner.pos).toEqual(1); + expect(body.imp[1].bidfloor).toEqual(0.01); + + expect(body.site.id).toEqual('108060'); + expect(body.site.page).toEqual(jasmine.any(String)); + expect(body.at).toEqual(1); + + expect(body.device).toBeDefined(); + expect(body.device.ua).toEqual(jasmine.any(String)); + expect(body.regs.ext.gdpr).toEqual(jasmine.any(Number)); + expect(body.user.ext.consent).toEqual(jasmine.any(String)); +} + +function getValidResponse(request, creative) { + var body = JSON.parse(request.body); + var response = { + seatbid: [ + { + bid: [ + { + adm: creative, + crid: '2101274', + impid: body.imp[0].id, + price: 2, + w: 300, + h: 250, + adomain: ['https://na13.salesforce.com'], + iurl: 'http://media.fastclick.net/win.bid', + id: 'htSlot1_0' + }, + { + adm: creative, + crid: '2166499', + impid: body.imp[1].id, + price: 2, + w: 120, + h: 600, + adomain: ['https://na13.salesforce.com'], + iurl: 'http://media.fastclick.net/win.bid', + id: 'htSlot1_1' + } + ] + } + ], + id: '_jjzp7ar12' + }; + + return JSON.stringify(response); +} + +function validateTargeting(targetingMap) { + expect(targetingMap).toEqual(jasmine.objectContaining({ + ix_conv_cpm: jasmine.arrayContaining([jasmine.stringMatching(/300x250_\d+/), jasmine.stringMatching(/120x600_\d+/)]), + ix_conv_id: jasmine.arrayContaining([jasmine.any(String)]) + })); +} + +function getPassResponse(request) { + var body = JSON.parse(request.body); + var response = { + seatbid: [ + { + bid: [ + { + impid: body.imp[0].id, + price: 0.0000, + id: 'htSlot1_0' + }, + { + impid: body.imp[1].id, + price: 0.0000, + id: 'htSlot1_1' + } + ] + } + ], + id: '_jjzp7ar12' + }; + + return JSON.stringify(response); +} + +function validateBidRequestWithPrivacy(request) { + var body = JSON.parse(request.body); + + expect(body.regs.ext.gdpr).toEqual(1); + expect(body.user.ext.consent).toEqual('TEST_GDPR_CONSENT_STRING'); +} + +module.exports = { + getPartnerId: getPartnerId, + getStatsId: getStatsId, + getCallbackType: getCallbackType, + getArchitecture: getArchitecture, + getConfig: getConfig, + getBidRequestRegex: getBidRequestRegex, + validateBidRequest: validateBidRequest, + getValidResponse: getValidResponse, + getPassResponse: getPassResponse, + validateTargeting: validateTargeting, + validateBidRequestWithPrivacy: validateBidRequestWithPrivacy +}; diff --git a/conversant/conversant-htb-validator.js b/conversant/conversant-htb-validator.js new file mode 100644 index 00000000..d9a66dbb --- /dev/null +++ b/conversant/conversant-htb-validator.js @@ -0,0 +1,96 @@ +/** + * @author: Partner + * @license: UNLICENSED + * + * @copyright: Copyright (c) 2017 by Index Exchange. All rights reserved. + * + * The information contained within this document is confidential, copyrighted + * and or a trade secret. No part of this document may be reproduced or + * distributed in any form or by any means, in whole or in part, without the + * prior written permission of Index Exchange. + */ + +/** + * This file contains the necessary validation for the partner configuration. + * This validation will be performed on the partner specific configuration object + * that is passed into the wrapper. The wrapper uses an outside library called + * schema-insepctor to perform the validation. Information about it can be found here: + * https://atinux.fr/schema-inspector/. + */ + +'use strict'; + +//////////////////////////////////////////////////////////////////////////////// +// Dependencies //////////////////////////////////////////////////////////////// +//////////////////////////////////////////////////////////////////////////////// + +var Inspector = require('../../../libs/external/schema-inspector.js'); + +// Var Inspector = require('schema-inspector'); + +//////////////////////////////////////////////////////////////////////////////// +// Main //////////////////////////////////////////////////////////////////////// +//////////////////////////////////////////////////////////////////////////////// + +function partnerValidator(configs) { + var result = Inspector.validate({ + type: 'object', + properties: { + siteId: { + type: 'string', + minLength: 1 + }, + xSlots: { + type: 'object', + properties: { + '*': { + type: 'object', + properties: { + placementId: { + type: 'string', + minLength: 1, + optional: true + }, + sizes: { + type: 'array', + items: { + type: 'array', + exactLength: 2, + items: { + type: 'integer' + } + } + }, + bidfloor: { + type: 'number', + optional: true + }, + position: { + type: 'integer', + optional: true + } + } + } + } + }, + mapping: { + type: 'object', + properties: { + '*': { + type: 'array', + items: { type: 'string' }, + minLength: 1 + } + } + } + } + }, configs); + + if (!result.valid) { + return result.format(); + } + + return null; +} + +module.exports = partnerValidator; diff --git a/conversant/conversant-htb.js b/conversant/conversant-htb.js new file mode 100644 index 00000000..ac26b53b --- /dev/null +++ b/conversant/conversant-htb.js @@ -0,0 +1,658 @@ +/** + * @author: Partner + * @license: UNLICENSED + * + * @copyright: Copyright (c) 2017 by Index Exchange. All rights reserved. + * + * The information contained within this document is confidential, copyrighted + * and or a trade secret. No part of this document may be reproduced or + * distributed in any form or by any means, in whole or in part, without the + * prior written permission of Index Exchange. + */ + +'use strict'; + +//////////////////////////////////////////////////////////////////////////////// +// Dependencies //////////////////////////////////////////////////////////////// +//////////////////////////////////////////////////////////////////////////////// + +var Browser = require('browser.js'); +var Classify = require('classify.js'); +var Constants = require('constants.js'); +var Partner = require('partner.js'); +var Size = require('size.js'); +var SpaceCamp = require('space-camp.js'); +var System = require('system.js'); +var Network = require('network.js'); + +var ComplianceService; +var RenderService; + +//? if (DEBUG) { +var ConfigValidators = require('config-validators.js'); +var PartnerSpecificValidator = require('conversant-htb-validator.js'); +var Scribe = require('scribe.js'); +var Whoopsie = require('whoopsie.js'); +//? } + +//////////////////////////////////////////////////////////////////////////////// +// Main //////////////////////////////////////////////////////////////////////// +//////////////////////////////////////////////////////////////////////////////// + +/** + * Conversant Header Tag Bidder module. + * + * @class + */ +function ConversantHtb(configs) { + /* Conversant endpoint only works with AJAX */ + if (!Network.isXhrSupported()) { + //? if (DEBUG) { + Scribe.warn('Partner ConversantHtb requires AJAX support. Aborting instantiation.'); + //? } + + return null; + } + + /* ===================================== + * Data + * ---------------------------------- */ + + /* Private + * ---------------------------------- */ + + /** + * Reference to the partner base class. + * + * @private {object} + */ + var __baseClass; + + /** + * Profile for this partner. + * + * @private {object} + */ + var __profile; + + var w = Browser.topWindow; + var n = w.navigator; + + /* ===================================== + * Functions + * ---------------------------------- */ + + /* Utilities + * ---------------------------------- */ + + /** + * Return the indicator value for do-not-track + */ + + function __getDNT() { + return n.doNotTrack === '1' || w.doNotTrack === '1' || n.msDoNotTrack === '1' || n.doNotTrack === 'yes' ? 1 : 0; + } + + /** + * Return the device object for the bid request + */ + + function __getDevice() { + return { + h: Browser.getScreenHeight(), + w: Browser.getScreenWidth(), + dnt: __getDNT(), + language: Browser.getLanguage(), + make: n.vendor ? n.vendor : '', + ua: Browser.getUserAgent() + }; + } + + /** + * Return site object for the bid request + */ + + function __getSite() { + return { + id: configs.siteId, + mobile: w.document.querySelector('meta[name="viewport"][content*="width=device-width"]') !== null ? 1 : 0, + page: Browser.getPageUrl() + }; + } + + /** + * Return an array of impressions for the bid request + */ + + function __getImps(returnParcels) { + var conversantImps = []; + var secure = Browser.getProtocol() + .search(/^https:/i) >= 0 ? 1 : 0; + + // Each parcel is a unique combination of a htSlot and xSlot. + // Since Conversant bid requests do not require unique placement ids, + // RequestIds are used instead. + + for (var i = 0; i < returnParcels.length; ++i) { + var parcel = returnParcels[i]; + var xSlot = parcel.xSlotRef; + var imp = {}; + var banner = {}; + + imp.id = parcel.requestId; + imp.secure = secure; + imp.displaymanager = '40834-index-client'; + imp.displaymanagerver = __profile.version; + + if (xSlot.hasOwnProperty('bidfloor')) { + imp.bidfloor = xSlot.bidfloor; + } + + if (xSlot.hasOwnProperty('placementId')) { + imp.tagid = xSlot.placementId; + } + + var format = []; + for (var sizeIdx = 0; sizeIdx < xSlot.sizes.length; ++sizeIdx) { + var size = xSlot.sizes[sizeIdx]; + format.push({ + w: size[0], + h: size[1] + }); + } + + banner.format = format; + + if (xSlot.hasOwnProperty('position')) { + banner.pos = xSlot.position; + } + + imp.banner = banner; + + conversantImps.push(imp); + } + + return conversantImps; + } + + /** + * Build and return the header bidding request + */ + + function __buildBidRequest(returnParcels) { + return { + id: System.generateUniqueId(), + imp: __getImps(returnParcels), + site: __getSite(), + device: __getDevice(), + at: 1 + }; + } + + /* Utilities + * ---------------------------------- */ + + /** + * Generates the request URL and query data to the endpoint for the xSlots + * in the given returnParcels. + * + * @param {object[]} returnParcels + * + * @return {object} + */ + function __generateRequestObj(returnParcels) { + /* ============================================================================= + * STEP 2 | Generate Request URL + * ----------------------------------------------------------------------------- + * + * Generate the URL to request demand from the partner endpoint using the provided + * returnParcels. The returnParcels is an array of objects each object containing + * an .xSlotRef which is a reference to the xSlot object from the partner configuration. + * Use this to retrieve the placements/xSlots you need to request for. + * + * If your partner is MRA, returnParcels will be an array of length one. If your + * partner is SRA, it will contain any number of entities. In any event, the full + * contents of the array should be able to fit into a single request and the + * return value of this function should similarly represent a single request to the + * endpoint. + * + * Return an object containing: + * queryUrl: the url for the request + * data: the query object containing a map of the query string paramaters + * + * callbackId: + * + * arbitrary id to match the request with the response in the callback function. If + * your endpoint supports passing in an arbitrary ID and returning it as part of the response + * please use the callbackType: Partner.CallbackTypes.ID and fill out the adResponseCallback. + * Also please provide this adResponseCallback to your bid request here so that the JSONP + * response calls it once it has completed. + * + * If your endpoint does not support passing in an ID, simply use + * Partner.CallbackTypes.CALLBACK_NAME and the wrapper will take care of handling request + * matching by generating unique callbacks for each request using the callbackId. + * + * If your endpoint is ajax only, please set the appropriate values in your profile for this, + * i.e. Partner.CallbackTypes.NONE and Partner.Requesttypes.AJAX. You also do not need to provide + * a callbackId in this case because there is no callback. + * + * The return object should look something like this: + * { + * url: 'http://bidserver.com/api/bids' // base request url for a GET/POST request + * data: { // query string object that will be attached to the base url + * slots: [ + * { + * placementId: 54321, + * sizes: [[300, 250]] + * },{ + * placementId: 12345, + * sizes: [[300, 600]] + * },{ + * placementId: 654321, + * sizes: [[728, 90]] + * } + * ], + * site: 'http://google.com' + * }, + * callbackId: '_23sd2ij4i1' //unique id used for pairing requests and responses + * } + */ + + /* ---------------------- PUT CODE HERE ------------------------------------ */ + var queryObj = {}; + + /* Change this to your bidder endpoint. */ + var baseUrl = Browser.getProtocol() + + '//web.hb.ad.cpe.dotomi.com/s2s/header/24?cb=' + System.generateUniqueId(); + + /* ---------------- Craft bid request using the above returnParcels --------- */ + queryObj = __buildBidRequest(returnParcels); + + /* ------------------------ Get consent information ------------------------- + * If you want to implement GDPR consent in your adapter, use the function + * ComplianceService.gdpr.getConsent() which will return an object. + * + * Here is what the values in that object mean: + * - applies: the boolean value indicating if the request is subject to + * GDPR regulations + * - consentString: the consent string developed by GDPR Consent Working + * Group under the auspices of IAB Europe + * + * The return object should look something like this: + * { + * applies: true, + * consentString: "BOQ7WlgOQ7WlgABABwAAABJOACgACAAQABA" + * } + * + * You can also determine whether or not the publisher has enabled privacy + * features in their wrapper by querying ComplianceService.isPrivacyEnabled(). + * + * This function will return a boolean, which indicates whether the wrapper's + * privacy features are on (true) or off (false). If they are off, the values + * returned from gdpr.getConsent() are safe defaults and no attempt has been + * made by the wrapper to contact a Consent Management Platform. + */ + var gdprStatus = ComplianceService.gdpr.getConsent(); + var uspStatus = ComplianceService.usp && ComplianceService.usp.getConsent(); + var privacyEnabled = ComplianceService.isPrivacyEnabled(); + + if (privacyEnabled) { + queryObj.regs = { + ext: { gdpr: gdprStatus.applies ? 1 : 0 } + }; + + if (uspStatus) { + // eslint-disable-next-line camelcase + queryObj.regs.ext.us_privacy = uspStatus.uspString; + } + + queryObj.user = { + ext: { consent: gdprStatus.consentString } + }; + } + + /* -------------------------------------------------------------------------- */ + + return { + url: baseUrl, + data: queryObj, + callbackId: queryObj.id, + + /* Signal a POST request and the content type */ + networkParamOverrides: { + method: 'POST' + } + }; + } + + /* ============================================================================= + * STEP 3 | Response callback + * ----------------------------------------------------------------------------- + * + * This generator is only necessary if the partner's endpoint has the ability + * to return an arbitrary ID that is sent to it. It should retrieve that ID from + * the response and save the response to adResponseStore keyed by that ID. + * + * If the endpoint does not have an appropriate field for this, set the profile's + * callback type to CallbackTypes.CALLBACK_NAME and omit this function. + */ + function adResponseCallback(adResponse) { + /* Get callbackId from adResponse here */ + var callbackId = 0; + + if (adResponse.hasOwnProperty('id')) { + callbackId = adResponse.id; + } else { + throw Whoopsie('Cnvr bid response missing id', adResponse); + } + + __baseClass._adResponseStore[callbackId] = adResponse; + } + + /* -------------------------------------------------------------------------- */ + + /* Helpers + * ---------------------------------- */ + + /* ============================================================================= + * STEP 5 | Rendering Pixel + * ----------------------------------------------------------------------------- + * + */ + + /** + * This function will render the pixel given. + * @param {string} pixelUrl Tracking pixel img url. + */ + function __renderPixel(pixelUrl) { + if (pixelUrl) { + Network.img({ + url: decodeURIComponent(pixelUrl), + method: 'GET' + }); + } + } + + /** + * Parses and extracts demand from adResponse according to the adapter and then attaches it + * to the corresponding bid's returnParcel in the correct format using targeting keys. + * + * @param {string} sessionId The sessionId, used for stats and other events. + * + * @param {any} adResponse This is the bid response as returned from the bid request, that was either + * passed to a JSONP callback or simply sent back via AJAX. + * + * @param {object[]} returnParcels The array of original parcels, SAME array that was passed to + * generateRequestObj to signal which slots need demand. In this funciton, the demand needs to be + * attached to each one of the objects for which the demand was originally requested for. + */ + function __parseResponse(sessionId, adResponse, returnParcels) { + /* ============================================================================= + * STEP 4 | Parse & store demand response + * ----------------------------------------------------------------------------- + * + * Fill the below variables with information about the bid from the partner, using + * the adResponse variable that contains your module adResponse. + */ + + /* This an array of all the bids in your response that will be iterated over below. Each of + * these will be mapped back to a returnParcel object using some criteria explained below. + * The following variables will also be parsed and attached to that returnParcel object as + * returned demand. + * + * Use the adResponse variable to extract your bid information and insert it into the + * bids array. Each element in the bids array should represent a single bid and should + * match up to a single element from the returnParcel array. + * + */ + + /* ---------- Process adResponse and extract the bids into the bids array ------------ */ + + var bids = []; + + // There should only be one seatbid, but just in case, flatten all bids into a single + // Array + + if (adResponse.hasOwnProperty('seatbid')) { + for (var k = 0; k < adResponse.seatbid.length; ++k) { + var seatbid = adResponse.seatbid[k]; + bids = bids.concat(seatbid.bid); + } + } + + /* --------------------------------------------------------------------------------- */ + + for (var j = 0; j < returnParcels.length; j++) { + var curReturnParcel = returnParcels[j]; + + var headerStatsInfo = {}; + var htSlotId = curReturnParcel.htSlot.getId(); + headerStatsInfo[htSlotId] = {}; + headerStatsInfo[htSlotId][curReturnParcel.requestId] = [curReturnParcel.xSlotName]; + + var curBid; + + for (var i = 0; i < bids.length; i++) { + /** + * This section maps internal returnParcels and demand returned from the bid request. + * In order to match them correctly, they must be matched via some criteria. This + * is usually some sort of placements or inventory codes. Please replace the someCriteria + * key to a key that represents the placement in the configuration and in the bid responses. + */ + + /* -------- Fill this out to find a matching bid for the current parcel --------- */ + if (curReturnParcel.requestId === bids[i].impid) { + curBid = bids[i]; + bids.splice(i, 1); + + break; + } + } + + /* No matching bid found so its a pass */ + if (!curBid) { + if (__profile.enabledAnalytics.requestTime) { + __baseClass._emitStatsEvent(sessionId, 'hs_slot_pass', headerStatsInfo); + } + curReturnParcel.pass = true; + + continue; + } + + /* ---------- Fill the bid variables with data from the bid response here. ------------ */ + + /* Using the above variable, curBid, extract various information about the bid and assign it to + * these local variables */ + + /* The bid price for the given slot */ + var bidPrice = curBid.price; + + /* The size of the given slot */ + var bidSize = [Number(curBid.w), Number(curBid.h)]; + + /* The creative/adm for the given slot that will be rendered if is the winner. + * Please make sure the URL is decoded and ready to be document.written. + */ + var bidCreative = curBid.adm; + + /* The dealId if applicable for this slot. */ + var bidDealId = ''; + + /* Explicitly pass */ + var bidIsPass = bidPrice <= 0; + + /* OPTIONAL: tracking pixel url to be fired AFTER rendering a winning creative. + * If firing a tracking pixel is not required or the pixel url is part of the adm, + * leave empty; + */ + var pixelUrl = ''; + + /* --------------------------------------------------------------------------------------- */ + + if (bidIsPass) { + //? if (DEBUG) { + Scribe.info(__profile.partnerId + ' returned pass for { id: ' + adResponse.id + ' }.'); + //? } + if (__profile.enabledAnalytics.requestTime) { + __baseClass._emitStatsEvent(sessionId, 'hs_slot_pass', headerStatsInfo); + } + curReturnParcel.pass = true; + + continue; + } + + if (__profile.enabledAnalytics.requestTime) { + __baseClass._emitStatsEvent(sessionId, 'hs_slot_bid', headerStatsInfo); + } + + curReturnParcel.size = bidSize; + curReturnParcel.targetingType = 'slot'; + curReturnParcel.targeting = {}; + var targetingCpm = ''; + + //? if (FEATURES.GPT_LINE_ITEMS) { + var sizeKey = Size.arrayToString(curReturnParcel.size); + targetingCpm = __baseClass._bidTransformers.targeting.apply(bidPrice); + + if (bidDealId !== '') { + curReturnParcel.targeting[__baseClass._configs.targetingKeys.pmid] = [sizeKey + '_' + bidDealId]; + curReturnParcel.targeting[__baseClass._configs.targetingKeys.pm] = [sizeKey + '_' + targetingCpm]; + } else { + curReturnParcel.targeting[__baseClass._configs.targetingKeys.om] = [sizeKey + '_' + targetingCpm]; + } + curReturnParcel.targeting[__baseClass._configs.targetingKeys.id] = [curReturnParcel.requestId]; + //? } + + //? if (FEATURES.RETURN_CREATIVE) { + curReturnParcel.adm = bidCreative; + //? } + + //? if (FEATURES.RETURN_PRICE) { + curReturnParcel.price = Number(__baseClass._bidTransformers.price.apply(bidPrice)); + //? } + + var pubKitAdId = RenderService.registerAd({ + sessionId: sessionId, + partnerId: __profile.partnerId, + adm: bidCreative, + requestId: curReturnParcel.requestId, + size: curReturnParcel.size, + price: bidDealId ? bidDealId : targetingCpm, + // eslint-disable-next-line max-len + timeOfExpiry: __profile.features.demandExpiry.enabled ? __profile.features.demandExpiry.value + System.now() : 0, + auxFn: __renderPixel, + auxArgs: [pixelUrl] + }); + + //? if (FEATURES.INTERNAL_RENDER) { + curReturnParcel.targeting.pubKitAdId = pubKitAdId; + //? } + } + } + + /* ===================================== + * Constructors + * ---------------------------------- */ + + (function __constructor() { + ComplianceService = SpaceCamp.services.ComplianceService; + RenderService = SpaceCamp.services.RenderService; + + /* ============================================================================= + * STEP 1 | Partner Configuration + * ----------------------------------------------------------------------------- + * + * Please fill out the below partner profile according to the steps in the README doc. + */ + + /* ---------- Please fill out this partner profile according to your module ------------ */ + __profile = { + partnerId: 'ConversantHtb', + namespace: 'ConversantHtb', + statsId: 'CONV', + version: '2.3.0', + targetingType: 'slot', + enabledAnalytics: { + requestTime: true + }, + features: { + demandExpiry: { + enabled: false, + value: 0 + }, + rateLimiting: { + enabled: false, + value: 0 + } + }, + targetingKeys: { + id: 'ix_conv_id', + om: 'ix_conv_cpm', + pm: 'ix_conv_cpm', + pmid: 'ix_conv_dealid' + }, + bidUnitInCents: 100, + lineItemType: Constants.LineItemTypes.ID_AND_SIZE, + callbackType: Partner.CallbackTypes.NONE, + architecture: Partner.Architectures.SRA, + requestType: Partner.RequestTypes.AJAX + }; + + /* --------------------------------------------------------------------------------------- */ + + //? if (DEBUG) { + var results = ConfigValidators.partnerBaseConfig(configs) || PartnerSpecificValidator(configs); + + if (results) { + throw Whoopsie('INVALID_CONFIG', results); + } + //? } + + __baseClass = Partner(__profile, configs, null, { + parseResponse: __parseResponse, + generateRequestObj: __generateRequestObj, + adResponseCallback: adResponseCallback + }); + })(); + + /* ===================================== + * Public Interface + * ---------------------------------- */ + + var derivedClass = { + /* Class Information + * ---------------------------------- */ + + //? if (DEBUG) { + __type__: 'ConversantHtb', + //? } + + //? if (TEST) { + __baseClass: __baseClass, + //? } + + /* Data + * ---------------------------------- */ + + //? if (TEST) { + profile: __profile, + //? } + + /* Functions + * ---------------------------------- */ + + //? if (TEST) { + parseResponse: __parseResponse, + generateRequestObj: __generateRequestObj, + adResponseCallback: adResponseCallback + //? } + }; + + return Classify.derive(__baseClass, derivedClass); +} + +//////////////////////////////////////////////////////////////////////////////// +// Exports ///////////////////////////////////////////////////////////////////// +//////////////////////////////////////////////////////////////////////////////// + +module.exports = ConversantHtb; diff --git a/just-premium/just-premium-htb.js b/just-premium/just-premium-htb.js index 28d67882..f6fc9881 100755 --- a/just-premium/just-premium-htb.js +++ b/just-premium/just-premium-htb.js @@ -126,7 +126,11 @@ function JustPremiumHtb(configs) { var baseUrl = Browser.getProtocol() + '//pre.ads.justpremium.com/v/2.0/t/ie'; var gdprStatus = ComplianceService.gdpr.getConsent(); + var uspConsentObj = ComplianceService.usp && ComplianceService.usp.getConsent(); /* ---------------- Craft bid request using the above returnParcels --------- */ + if (uspConsentObj) { + queryObj.usPrivacy = uspConsentObj.uspString; + } queryObj.hostname = Browser.getHostname(); queryObj.protocol = Browser.getProtocol().replace(':', ''); queryObj.sw = Browser.getScreenWidth(); diff --git a/kargo/CHANGES.md b/kargo/CHANGES.md index ba72aaf1..6e53f1d2 100644 --- a/kargo/CHANGES.md +++ b/kargo/CHANGES.md @@ -1,11 +1,13 @@ -Version 2.3.0 -============= +# 2.5.0 +- Add GDPR support -- Add CCPA support +# 2.4.0 +- Add Liveramp IDL Envelope support -Version 2.0.0 -============= +# 2.3.0 +- Add CCPA support +# 2.0.0 - Adding TDID support - Use localStorage as primary lookup for Kargo data, falling back to cookies as secondary - Adding session ID to the request to keep track of requests per instantiation diff --git a/kargo/DOCUMENTATION.md b/kargo/DOCUMENTATION.md index 615bc0ce..bedc8f3c 100644 --- a/kargo/DOCUMENTATION.md +++ b/kargo/DOCUMENTATION.md @@ -2,7 +2,7 @@ ## General Compatibility |Feature| | |---|---| -| Consent | No | +| Consent | Yes | | Native Ad Support | Yes | | SafeFrame Support | No | | PMP Support | Yes | @@ -67,9 +67,17 @@ "kargoID": "", "clientID": "", "tdID": "", + "idlEnv": "", + "identityData": null, "crbIDs": {}, "optOut": false, - "usp": "1YNN" + "usp": "1YNN", + "gdpr": { + "consent": "", + "applies": false, + "version": 2, + "addtlConsent": "" + } }, "krux": { "userID": null, @@ -184,4 +192,4 @@ } } } -``` \ No newline at end of file +``` diff --git a/kargo/kargo-htb-system-tests.js b/kargo/kargo-htb-system-tests.js index 5547f1c0..c79d2789 100644 --- a/kargo/kargo-htb-system-tests.js +++ b/kargo/kargo-htb-system-tests.js @@ -55,7 +55,10 @@ function validateBidRequest(request) { expect(r.userIDs.kargoID).toBeDefined(); expect(r.userIDs.optOut).toBeDefined(); expect(r.userIDs.tdID).toBeDefined(); + expect(r.userIDs.idlEnv).toBeDefined(); expect(r.userIDs.crbIDs).toBeDefined(); + expect(r.userIDs.usp).toBeDefined(); + expect(r.userIDs.gdpr).toBeDefined(); } function getValidResponse(request, creative) { diff --git a/kargo/kargo-htb.js b/kargo/kargo-htb.js index ed240f85..398819bd 100644 --- a/kargo/kargo-htb.js +++ b/kargo/kargo-htb.js @@ -121,6 +121,38 @@ function KargoHtb(configs) { return unifiedID; } + function __getIDLEnvelope(returnParcels) { + var idlEnvelope = ''; + var uids = [] + if (returnParcels && + returnParcels.length && + returnParcels[0].identityData && + returnParcels[0].identityData.LiveRampIp && + returnParcels[0].identityData.LiveRampIp.data && + returnParcels[0].identityData.LiveRampIp.data.uids) { + uids = returnParcels[0].identityData.LiveRampIp.data.uids; + } else { + return idlEnvelope; + } + for (var i = 0; i < uids.length; i++) { + if (uids[i].ext && + uids[i].ext.rtiPartner && + uids[i].ext.rtiPartner === 'idl') { + idlEnvelope = uids[i].id; + break; + } + }; + return idlEnvelope; + } + + function __getIdentityData(returnParcels) { + if (returnParcels && returnParcels.length) { + return returnParcels[0].identityData; + } else { + return null; + } + } + function __getCrbFromCookie() { try { var crb = JSON.parse(decodeURIComponent(Browser.readCookie('krg_crb'))); @@ -168,15 +200,29 @@ function KargoHtb(configs) { var crb = __getCrb(); var privacyEnabled = ComplianceService.isPrivacyEnabled(); var uspConsentObj = ComplianceService.usp && ComplianceService.usp.getConsent(); + var gdprConsentObj = ComplianceService.gdpr && ComplianceService.gdpr.getConsent(2); - return { + var userIds = { kargoID: crb.userId || '', clientID: crb.clientId || '', tdID: __getTDID(returnParcels), + idlEnv: __getIDLEnvelope(returnParcels), + identityData: __getIdentityData(returnParcels), crbIDs: crb.syncIds || {}, optOut: crb.optOut || false, usp: privacyEnabled && uspConsentObj ? uspConsentObj.uspString : null }; + + if (privacyEnabled && gdprConsentObj) { + userIds['gdpr'] = { + consent: gdprConsentObj.consentString || '', + applies: gdprConsentObj.applies ? true : false, + version: 2, + addtlConsent: gdprConsentObj.addtlConsent + } + } + + return userIds; } function __getKruxDmpData() { @@ -584,7 +630,7 @@ function KargoHtb(configs) { // Unique partner identifier statsId: 'KARG', - version: '2.2.1', + version: '2.5.0', targetingType: 'slot', enabledAnalytics: { requestTime: true diff --git a/rubicon/CHANGES.md b/rubicon/CHANGES.md index e1b417b5..ce112ca8 100644 --- a/rubicon/CHANGES.md +++ b/rubicon/CHANGES.md @@ -1,3 +1,9 @@ +# 2.1.6 + +- Adapter changes: + - schain support: reads the schain object to create a serialized obj for sending with `fastlane.json` as `rp_schain` + - CCPA support + # 2.1.5 - Adapter changes: diff --git a/rubicon/rubicon-htb-system-tests.js b/rubicon/rubicon-htb-system-tests.js index 6d17c881..78bf8cb4 100644 --- a/rubicon/rubicon-htb-system-tests.js +++ b/rubicon/rubicon-htb-system-tests.js @@ -37,6 +37,8 @@ function validateBidRequest(request) { expect(r.alt_size_ids).toBe('10'); expect(r.rf).toEqual(jasmine.anything()); + + expect(r.rp_schain).toBeDefined(); } function validateBidRequestWithPrivacy(request) { @@ -45,6 +47,8 @@ function validateBidRequestWithPrivacy(request) { expect(r.gdpr).toBe('1'); expect(r.gdpr_consent).toBe('TEST_GDPR_CONSENT_STRING'); + + expect(r.us_privacy).toBeDefined(); } function getConfig() { @@ -56,6 +60,22 @@ function getConfig() { zoneId: '556677', sizes: [[300, 250], [300, 600]] } + }, + schain: { + ver: '1.0', + complete: 1, + nodes: [ + { + asi: 'indirectseller.com', + sid: '00001', + hp: 1 + }, + { + asi: 'indirectseller-2.com', + sid: '00002', + hp: 1 + } + ] } }; } diff --git a/rubicon/rubicon-htb-validator.js b/rubicon/rubicon-htb-validator.js index 9f8004c3..966f82d1 100644 --- a/rubicon/rubicon-htb-validator.js +++ b/rubicon/rubicon-htb-validator.js @@ -314,6 +314,15 @@ function partnerValidator(configs) { } } } + }, + schain: { + optional: true, + type: 'object', + properties: { + nodes: { + type: 'array' + } + } } } }, configs); diff --git a/rubicon/rubicon-htb.js b/rubicon/rubicon-htb.js index 271ee136..17e7d7a9 100644 --- a/rubicon/rubicon-htb.js +++ b/rubicon/rubicon-htb.js @@ -303,6 +303,69 @@ function RubiconModule(configs) { return _dt; } + /** + * Properly sorts schain object params + * @param {Array} nodes + * @returns {String} + */ + + function __serializeSupplyChainNodes(nodes) { + return nodes.map(function (node) { + return [ + 'asi', + 'sid', + 'hp', + 'rid', + 'name', + 'domain' + ].map(function (prop) { + return encodeURIComponent(node[prop] || ''); + }) + .join(','); + }) + .join('!'); + } + + /** + * Validate schain params + * @param {Object} schain + * @returns {boolean} + */ + + function __hasValidSupplyChainParams(schain) { + if (!schain.nodes) { + return false; + } + + return schain.nodes.reduce(function (nodeStatus, node) { + if (!nodeStatus) { + return nodeStatus; + } + + return [ + 'asi', + 'sid', + 'hp' + ].every(function (field) { + return node[field]; + }); + }, true); + } + + /** + * Serializes schain params according to OpenRTB requirements + * @param {Object} schain + * @returns {String} + */ + + function __serializeSupplyChain(schain) { + if (!__hasValidSupplyChainParams(schain)) { + return ''; + } + + return schain.ver + ',' + schain.complete + '!' + __serializeSupplyChainNodes(schain.nodes); + } + /** * Generates the request URL to the endpoint for the xSlots in the given * returnParcels. @@ -338,7 +401,6 @@ function RubiconModule(configs) { strict: true, properties: { keywords: { - optional: true, type: 'array', items: { type: 'string' @@ -402,8 +464,6 @@ function RubiconModule(configs) { var rubiSizeIds = __mapSizesToRubiconSizeIds(parcel.xSlotRef.sizes); var referrer = Browser.getPageUrl(); - var gdprConsent = ComplianceService.gdpr && ComplianceService.gdpr.getConsent(); - var privacyEnabled = ComplianceService.isPrivacyEnabled(); /* eslint-disable camelcase */ var queryObj = { account_id: configs.accountId, @@ -423,17 +483,29 @@ function RubiconModule(configs) { if (slotFirstPartyData.position) { queryObj.p_pos = slotFirstPartyData.position; } - /* eslint-enable camelcase */ - if (gdprConsent && privacyEnabled && typeof gdprConsent === 'object') { - if (typeof gdprConsent.applies === 'boolean') { - queryObj.gdpr = Number(gdprConsent.applies); + if (ComplianceService.isPrivacyEnabled()) { + var gdprConsent = ComplianceService.gdpr && ComplianceService.gdpr.getConsent(); + if (gdprConsent && typeof gdprConsent === 'object') { + if (typeof gdprConsent.applies === 'boolean') { + queryObj.gdpr = Number(gdprConsent.applies); + } + queryObj.gdpr_consent = gdprConsent.consentString; + } + + var uspConsent = ComplianceService.usp && ComplianceService.usp.getConsent(); + if (uspConsent && typeof uspConsent === 'object') { + queryObj.us_privacy = encodeURIComponent(uspConsent.uspString); } - /* eslint-disable camelcase */ - queryObj.gdpr_consent = gdprConsent.consentString; - /* eslint-enable camelcase */ } + // Add schain if it exists and contains all required fields + if (configs.schain && __hasValidSupplyChainParams(configs.schain)) { + queryObj.rp_schain = __serializeSupplyChain(configs.schain); + } + + /* eslint-enable camelcase */ + for (var pageInv in pageFirstPartyData.inventory) { if (!pageFirstPartyData.inventory.hasOwnProperty(pageInv)) { continue; @@ -486,10 +558,9 @@ function RubiconModule(configs) { } if (rubiSizeIds.length > 1) { - /* eslint-disable camelcase */ + // eslint-disable-next-line camelcase queryObj.alt_size_ids = rubiSizeIds.slice(1) .join(','); - /* eslint-enable camelcase */ } return { @@ -749,7 +820,7 @@ function RubiconModule(configs) { partnerId: 'RubiconHtb', namespace: 'RubiconHtb', statsId: 'RUBI', - version: '2.1.5', + version: '2.1.6', targetingType: 'slot', enabledAnalytics: { requestTime: true diff --git a/thirty-three-across/CHANGES.md b/thirty-three-across/CHANGES.md index f50599ec..6ded900e 100644 --- a/thirty-three-across/CHANGES.md +++ b/thirty-three-across/CHANGES.md @@ -1,2 +1,5 @@ +# 2.0.1 +- fix the siteID validation + # 2.0.0 - Initial adapter implementation diff --git a/thirty-three-across/thirty-three-across-htb-system-tests.js b/thirty-three-across/thirty-three-across-htb-system-tests.js index 2249bb4b..feb07eda 100644 --- a/thirty-three-across/thirty-three-across-htb-system-tests.js +++ b/thirty-three-across/thirty-three-across-htb-system-tests.js @@ -25,7 +25,7 @@ function getArchitecture() { function getConfig() { return { - siteId: 'acbdefgABCDEFG-1234567', + siteId: 'abcdef_ABCDEF-12345678', test: 1, xSlots: { 1: { diff --git a/thirty-three-across/thirty-three-across-htb-validator.js b/thirty-three-across/thirty-three-across-htb-validator.js index 3e9dc4fc..c263e466 100644 --- a/thirty-three-across/thirty-three-across-htb-validator.js +++ b/thirty-three-across/thirty-three-across-htb-validator.js @@ -25,7 +25,7 @@ function partnerValidator(configs) { properties: { siteId: { type: 'string', - pattern: /^[a-zA-Z0-9-]{22}$/ + pattern: /^[a-zA-Z0-9_-]{22}$/ }, test: { type: 'number', diff --git a/thirty-three-across/thirty-three-across-htb.js b/thirty-three-across/thirty-three-across-htb.js index a7682fc4..44ce5523 100644 --- a/thirty-three-across/thirty-three-across-htb.js +++ b/thirty-three-across/thirty-three-across-htb.js @@ -55,7 +55,7 @@ function ThirtyThreeAcrossHtb(configs) { var __profile; var _indexLibVersion = SpaceCamp.version; - var _adapterVersion = '2.0.0'; + var _adapterVersion = '2.0.1'; var SYNC_ENDPOINT = 'https://ssc-cms.33across.com/ps/?m=xch&rt=html&ru=deb'; var DEFAULT_SYNC_ID = 'zzz000000000003zzz';