diff --git a/modules/valuadBidAdapter.js b/modules/valuadBidAdapter.js new file mode 100644 index 00000000000..86cda06f586 --- /dev/null +++ b/modules/valuadBidAdapter.js @@ -0,0 +1,394 @@ +import { registerBidder } from '../src/adapters/bidderFactory.js'; +import { BANNER } from '../src/mediaTypes.js'; +import { ortbConverter } from '../libraries/ortbConverter/converter.js'; +import { + cleanObj, + deepAccess, + deepSetValue, + generateUUID, + getWindowSelf, + getWindowTop, + canAccessWindowTop, + getDNT, + logInfo, + triggerPixel, + getWinDimensions, +} from '../src/utils.js'; +import { getGptSlotInfoForAdUnitCode } from '../libraries/gptUtils/gptUtils.js'; +import { config } from '../src/config.js'; +import { parseDomain } from '../src/refererDetection.js'; +import { getBoundingClientRect } from '../libraries/boundingClientRect/boundingClientRect.js'; + +const BIDDER_CODE = 'valuad'; +const AD_URL = 'https://rtb.valuad.io/adapter'; +const WON_URL = 'https://hb-dot-valuad.appspot.com/adapter/win'; + +export const _VALUAD = (function() { + const w = (canAccessWindowTop()) ? getWindowTop() : getWindowSelf(); + + w.VALUAD = w.VALUAD || {}; + w.VALUAD.pageviewId = w.VALUAD.pageviewId || generateUUID(); + w.VALUAD.sessionId = w.VALUAD.sessionId || generateUUID(); + w.VALUAD.sessionStartTime = w.VALUAD.sessionStartTime || Date.now(); + w.VALUAD.pageLoadTime = w.VALUAD.pageLoadTime || window.performance?.timing?.domContentLoadedEventEnd - window.performance?.timing?.navigationStart; + w.VALUAD.userActivity = w.VALUAD.userActivity || { + lastActivityTime: Date.now(), + pageviewCount: (w.VALUAD.userActivity?.pageviewCount || 0) + 1 + }; + + return w.VALUAD; +})(); + +// Helper functions to enrich data +function getDevice() { + const language = navigator.language ? 'language' : 'userLanguage'; + const deviceInfo = { + userAgent: navigator.userAgent, + language: navigator[language], + dnt: getDNT() ? 1 : 0, + js: 1, + geo: {} + }; + + const { innerWidth: windowWidth, innerHeight: windowHeight, screen } = getWinDimensions(); + // Get screen dimensions + if (window.screen) { + deviceInfo.w = screen.width; + deviceInfo.h = screen.height; + } + + // Get viewport dimensions + deviceInfo.ext = { + vpw: windowWidth, + vph: windowHeight + }; + + return deviceInfo; +} + +function getSite(bidderRequest) { + const { refererInfo } = bidderRequest; + const siteInfo = { + domain: parseDomain(refererInfo.topmostLocation) || '', + page: refererInfo.topmostLocation || '', + referrer: refererInfo.ref || getWindowSelf().document.referrer || '', + top: refererInfo.reachedTop + }; + + // Add page metadata if available + const meta = document.querySelector('meta[name="keywords"]'); + if (meta && meta.content) { + siteInfo.keywords = meta.content; + } + + return siteInfo; +} + +function getSession() { + return { + id: _VALUAD.sessionId, + startTime: _VALUAD.sessionStartTime, + lastActivityTime: _VALUAD.userActivity.lastActivityTime, + pageviewCount: _VALUAD.userActivity.pageviewCount, + pageLoadTime: _VALUAD.pageLoadTime || 0, + new: _VALUAD.userActivity.pageviewCount === 1 + }; +} + +// Add detailed ad unit position detection +function detectAdUnitPosition(adUnitCode) { + const element = document.getElementById(adUnitCode) || document.getElementById(getGptSlotInfoForAdUnitCode(adUnitCode)?.divId); + if (!element) return null; + + const rect = getBoundingClientRect(element); + const docElement = document.documentElement; + const pageWidth = docElement.clientWidth; + const pageHeight = docElement.scrollHeight; + + return { + x: Math.round(rect.left + window.pageXOffset), + y: Math.round(rect.top + window.pageYOffset), + w: Math.round(rect.width), + h: Math.round(rect.height), + position: `${Math.round(rect.left + window.pageXOffset)}x${Math.round(rect.top + window.pageYOffset)}`, + viewportVisibility: calculateVisibility(rect), + pageSize: `${pageWidth}x${pageHeight}` + }; +} + +function calculateVisibility(rect) { + const { innerWidth: windowWidth, innerHeight: windowHeight } = getWinDimensions(); + + // Element is not in viewport + if (rect.bottom < 0 || rect.right < 0 || rect.top > windowHeight || rect.left > windowWidth) { + return 0; + } + + // Calculate visible area + const visibleHeight = Math.min(rect.bottom, windowHeight) - Math.max(rect.top, 0); + const visibleWidth = Math.min(rect.right, windowWidth) - Math.max(rect.left, 0); + const visibleArea = visibleHeight * visibleWidth; + const totalArea = rect.height * rect.width; + + return totalArea > 0 ? visibleArea / totalArea : 0; +} + +function getGdprConsent(bidderRequest) { + if (!deepAccess(bidderRequest, 'gdprConsent')) { + return false; + } + + const { + apiVersion, + gdprApplies, + consentString, + allowAuctionWithoutConsent + } = bidderRequest.gdprConsent; + + return cleanObj({ + apiVersion, + consentString, + consentRequired: gdprApplies ? 1 : 0, + allowAuctionWithoutConsent: allowAuctionWithoutConsent ? 1 : 0 + }); +} + +// Enhanced ORTBConverter with additional data +const converter = ortbConverter({ + context: { + netRevenue: true, + ttl: 30 + }, + request(buildRequest, imps, bidderRequest, context) { + const request = buildRequest(imps, bidderRequest, context); + const device = getDevice(); + const site = getSite(bidderRequest); + const session = getSession(); + + const gdpr = getGdprConsent(bidderRequest); + const uspConsent = deepAccess(bidderRequest, 'uspConsent') || ''; + const coppa = config.getConfig('coppa') === true ? 1 : 0; + const { gpp, gpp_sid: gppSid } = deepAccess(bidderRequest, 'ortb2.regs', {}); + const dsa = deepAccess(bidderRequest, 'ortb2.regs.ext.dsa'); + + // Ensure we have required extensions + deepSetValue(request, 'device', {...request.device, ...device}); + deepSetValue(request, 'site', {...request.site, ...site}); + + deepSetValue(request, 'regs', { + gdpr: gdpr.consentRequired || 0, + coppa: coppa, + us_privacy: uspConsent, + ext: { + gdpr_conset: gdpr.consentString || '', + gpp: gpp || '', + gppSid: gppSid || [], + dsa: dsa, + } + }); + + const { innerWidth: windowWidth, innerHeight: windowHeight } = getWinDimensions(); + deepSetValue(request, 'site.ext.data.valuad_rtd', { + pageviewId: _VALUAD.pageviewId, + session: session, + features: { + page_dimensions: `${document.documentElement.scrollWidth}x${document.documentElement.scrollHeight}`, + viewport_dimensions: `${windowWidth}x${windowHeight}`, + user_timestamp: Math.floor(Date.now() / 1000), + dom_loading: window.performance?.timing?.domContentLoadedEventEnd - window.performance?.timing?.navigationStart + } + }); + + // Add bid parameters + if (bidderRequest && bidderRequest.bids && bidderRequest.bids.length) { + deepSetValue(request, 'ext.params', bidderRequest.bids[0].params); + } + + // Set currency to USD + deepSetValue(request, 'cur', ['USD']); + + // Add schain if present + const schain = deepAccess(bidderRequest.bids[0], 'schain'); + if (schain) { + deepSetValue(request, 'source.ext.schain', schain); + } + + // Add eids if present + const eids = deepAccess(bidderRequest.bids[0], 'userIdAsEids'); + if (eids) { + deepSetValue(request, 'user.ext.eids', eids); + } + + const ortb2 = bidderRequest.ortb2 || {}; + if (ortb2.site?.ext?.data) { + deepSetValue(request, 'site.ext.data', { + ...request.site.ext.data, + ...ortb2.site.ext.data + }); + } + + const tmax = bidderRequest.timeout; + if (tmax) { + deepSetValue(request, 'tmax', tmax); + } + + return request; + }, + + imp(buildImp, bid, context) { + const imp = buildImp(bid, context); + + // Add additional impression data + const positionData = detectAdUnitPosition(bid.adUnitCode); + if (positionData) { + deepSetValue(imp, 'ext.data.adg_rtd.adunit_position', positionData.position); + deepSetValue(imp, 'ext.data.viewability', positionData.viewportVisibility); + } + + // GPT information + const gptInfo = getGptSlotInfoForAdUnitCode(bid.adUnitCode); + if (gptInfo) { + deepSetValue(imp, 'ext.data.adserver', { + name: 'gam', + adslot: gptInfo.gptSlot + }); + deepSetValue(imp, 'ext.data.pbadslot', gptInfo.gptSlot); + + // If not already set, add gpid + if (!imp.ext.gpid && gptInfo.gptSlot) { + deepSetValue(imp, 'ext.gpid', gptInfo.gptSlot); + } + } + + // Handle price floors + if (typeof bid.getFloor === 'function') { + try { + const mediaType = Object.keys(bid.mediaTypes)[0]; + let size; + + if (mediaType === BANNER) { + size = bid.mediaTypes.banner.sizes && bid.mediaTypes.banner.sizes[0]; + } + + if (size) { + const floor = bid.getFloor({ + currency: 'USD', + mediaType, + size + }); + + if (floor && !isNaN(floor.floor) && floor.currency === 'USD') { + imp.bidfloor = floor.floor; + imp.bidfloorcur = 'USD'; + } + } + } catch (e) { + logInfo('Valuad: Error getting floor', e); + } + } + + return imp; + }, + + bidResponse(buildBidResponse, bid, context) { + let bidResponse; + try { + bidResponse = buildBidResponse(bid, context); + + if (bidResponse) { + if (bid.vbid) { + bidResponse.vbid = bid.vbid; + } + if (context.bidRequest?.params?.placementId) { + bidResponse.vid = context.bidRequest.params.placementId; + } + } + } catch (e) { + logInfo('[VALUAD CONVERTER] Error calling buildBidResponse:', e, 'Bid:', bid); + return; + } + return bidResponse; + }, +}); + +function isBidRequestValid(bid = {}) { + const { params, bidId, mediaTypes } = bid; + + const foundKeys = bid && bid.params && bid.params.placementId; + let valid = Boolean(bidId && params && foundKeys); + + if (mediaTypes && mediaTypes[BANNER]) { + valid = valid && Boolean(mediaTypes[BANNER] && mediaTypes[BANNER].sizes); + } else { + valid = false; + } + + return valid; +} + +function buildRequests(validBidRequests = [], bidderRequest = {}) { + // Add bid-level metadata for our server to use + validBidRequests = validBidRequests.map(req => { + req.valuadMeta = { + pageviewId: _VALUAD.pageviewId, + adUnitPosition: detectAdUnitPosition(req.adUnitCode) + }; + return req; + }); + + const data = converter.toORTB({ validBidRequests, bidderRequest }); + + // Update session data + _VALUAD.userActivity.lastActivityTime = Date.now(); + + return [{ + method: 'POST', + url: AD_URL, + data + }]; +} + +function interpretResponse(response, request) { + // Restore original call, remove logging and safe navigation + const bidResponses = converter.fromORTB({response: response.body, request: request.data}).bids; + + // Restore original server-side data processing + if (response.body && response.body.ext && response.body.ext.valuad) { + _VALUAD.serverData = response.body.ext.valuad; + } + + return bidResponses; +} + +function getUserSyncs(syncOptions, serverResponses) { + if (!serverResponses.length || serverResponses[0].body === '' || !serverResponses[0].body.userSyncs) { + return false; + } + + return serverResponses[0].body.userSyncs.map(sync => ({ + type: sync.type === 'iframe' ? 'iframe' : 'image', + url: sync.url + })); +} + +function onBidWon(bid) { + const { + adUnitCode, adUnitId, auctionId, bidder, cpm, currency, originalCpm, originalCurrency, size, vbid, vid, + } = bid; + const bidStr = JSON.stringify({ + adUnitCode, adUnitId, auctionId, bidder, cpm, currency, originalCpm, originalCurrency, size, vbid, vid, + }); + const encodedBidStr = window.btoa(bidStr); + triggerPixel(WON_URL + '?b=' + encodedBidStr); +} + +export const spec = { + code: BIDDER_CODE, + supportedMediaTypes: [BANNER], + isBidRequestValid, + buildRequests, + interpretResponse, + getUserSyncs, + onBidWon, +}; +registerBidder(spec); diff --git a/modules/valuadBidAdapter.md b/modules/valuadBidAdapter.md new file mode 100644 index 00000000000..d2705a7d8fb --- /dev/null +++ b/modules/valuadBidAdapter.md @@ -0,0 +1,30 @@ +# Overview + +**Module Name**: Valuad Bid Adapter +**Module Type**: Bidder Adapter +**Maintainer**: natan@valuad.io + +# Description + + +Module that connects to Valuad.io demand sources. +Valuad bid adapter supports Banner format only. + +# Test Parameters + +```js + const adUnits = [{ + code: 'valuad-test-div', + mediaTypes: { + banner: { + sizes: [[300, 250]] + } + }, + bids: [{ + bidder: 'valuad', + params: { + placementId: '00000', // REQUIRED + } + }] + }]; +``` diff --git a/test/spec/modules/valuadBidAdapter_spec.js b/test/spec/modules/valuadBidAdapter_spec.js new file mode 100644 index 00000000000..9a8f13830bf --- /dev/null +++ b/test/spec/modules/valuadBidAdapter_spec.js @@ -0,0 +1,534 @@ +import { expect, util } from 'chai'; +import * as sinon from 'sinon'; +import { spec, _VALUAD } from 'modules/valuadBidAdapter.js'; +import { newBidder } from 'src/adapters/bidderFactory.js'; +import { BANNER } from 'src/mediaTypes.js'; +import { deepClone, generateUUID } from 'src/utils.js'; +import { config } from 'src/config.js'; +import * as utils from 'src/utils.js'; +import * as gptUtils from 'libraries/gptUtils/gptUtils.js'; +import * as refererDetection from 'src/refererDetection.js'; +import * as BoundingClientRect from 'libraries/boundingClientRect/boundingClientRect.js'; + +const ENDPOINT = 'https://rtb.valuad.io/adapter'; +const WON_URL = 'https://hb-dot-valuad.appspot.com/adapter/win'; + +describe('ValuadAdapter', function () { + const adapter = newBidder(spec); + let requestToServer; + let validBidRequests; + let bidderRequest; + let sandbox; + let clock; + + before(function() { + validBidRequests = [ + { + bidder: 'valuad', + params: { + placementId: 'test-placement-id-1' + }, + adUnitCode: 'adunit-code-1', + mediaTypes: { + [BANNER]: { + sizes: [[300, 250], [300, 600]] + } + }, + bidId: 'bid-id-1', + bidderRequestId: 'br-id-1', + auctionId: 'auc-id-1', + transactionId: 'txn-id-1' + } + ]; + + bidderRequest = { + bidderCode: 'valuad', + auctionId: 'auc-id-1', + bidderRequestId: 'br-id-1', + bids: validBidRequests, + refererInfo: { + topmostLocation: 'http://test.com/page', + ref: 'http://referrer.com', + reachedTop: true + }, + timeout: 3000, + gdprConsent: { + apiVersion: 2, + gdprApplies: true, + consentString: 'test-consent-string', + allowAuctionWithoutConsent: false + }, + uspConsent: '1YN-', + ortb2: { + regs: { + gpp: 'test-gpp-string', + gpp_sid: [7], + ext: { + dsa: { behalf: 'advertiser', paid: 'advertiser' } + } + }, + site: { + ext: { + data: { pageType: 'article' } + } + }, + device: { + w: 1920, + h: 1080, + language: 'en-US' + } + } + }; + }); + + beforeEach(function () { + sandbox = sinon.sandbox.create(); + clock = sinon.useFakeTimers(); + + // Stub utility functions + sandbox.stub(utils, 'getWindowTop').returns({ + location: { href: 'http://test.com/page' }, + document: { + referrer: 'http://referrer.com', + documentElement: { + clientWidth: 1200, + scrollHeight: 2000, + scrollWidth: 1200 + } + }, + innerWidth: 1200, + innerHeight: 800, + screen: { width: 1920, height: 1080 }, + pageXOffset: 0, + pageYOffset: 0 + }); + + sandbox.stub(utils, 'getWindowSelf').returns({ + location: { href: 'http://test.com/page' }, + document: { + referrer: 'http://referrer.com', + documentElement: { + clientWidth: 1200, + scrollHeight: 2000, + scrollWidth: 1200 + } + }, + innerWidth: 1200, + innerHeight: 800, + screen: { width: 1920, height: 1080 }, + pageXOffset: 0, + pageYOffset: 0 + }); + + sandbox.stub(utils, 'canAccessWindowTop').returns(true); + sandbox.stub(utils, 'getDNT').returns(false); + sandbox.stub(utils, 'generateUUID').returns('test-uuid'); + + sandbox.stub(refererDetection, 'parseDomain').returns('test.com'); + + sandbox.stub(gptUtils, 'getGptSlotInfoForAdUnitCode').returns({ + gptSlot: '/123/adunit', + divId: 'div-gpt-ad-123' + }); + + sandbox.stub(config, 'getConfig').withArgs('coppa').returns(false); + + sandbox.stub(BoundingClientRect, 'getBoundingClientRect').returns({ + left: 10, + top: 20, + right: 310, + bottom: 270, + width: 300, + height: 250 + }); + + _VALUAD.pageviewId = 'test-pageview-id'; + _VALUAD.sessionId = 'test-session-id'; + _VALUAD.sessionStartTime = 1678886400000; + _VALUAD.pageLoadTime = 900; + _VALUAD.userActivity = { lastActivityTime: 1678886405000, pageviewCount: 1 }; + + requestToServer = spec.buildRequests(validBidRequests, bidderRequest)[0]; + }); + + afterEach(function () { + sandbox.restore(); + clock.restore(); + }); + + describe('inherited functions', function () { + it('should exist and be a function', function () { + expect(adapter.callBids).to.exist.and.to.be.a('function'); + }); + }); + + describe('isBidRequestValid', function () { + let bid = { + bidder: 'valuad', + params: { + placementId: 'test-placement-id' + }, + adUnitCode: 'adunit-code', + mediaTypes: { + [BANNER]: { + sizes: [[300, 250], [300, 600]] + } + }, + bidId: '30b31c1838de1e', + bidderRequestId: '22edbae2733bf6', + auctionId: '1d1a030790a475', + }; + + it('should return true for a valid banner bid request', function () { + expect(spec.isBidRequestValid(bid)).to.equal(true); + }); + + it('should return false when placementId is missing', function () { + let invalidBid = deepClone(bid); + delete invalidBid.params.placementId; + expect(spec.isBidRequestValid(invalidBid)).to.equal(false); + }); + + it('should return false when params are missing', function () { + let invalidBid = deepClone(bid); + delete invalidBid.params; + expect(spec.isBidRequestValid(invalidBid)).to.equal(false); + }); + + it('should return false when bidId is missing', function () { + let invalidBid = deepClone(bid); + delete invalidBid.bidId; + expect(spec.isBidRequestValid(invalidBid)).to.equal(false); + }); + + it('should return false when mediaTypes is missing', function () { + let invalidBid = deepClone(bid); + delete invalidBid.mediaTypes; + expect(spec.isBidRequestValid(invalidBid)).to.equal(false); + }); + + it('should return false when banner sizes are missing', function () { + let invalidBid = deepClone(bid); + delete invalidBid.mediaTypes[BANNER].sizes; + expect(spec.isBidRequestValid(invalidBid)).to.equal(false); + }); + }); + + describe('buildRequests', function () { + it('should return a valid server request object', function () { + expect(requestToServer).to.exist; + expect(requestToServer).to.be.an('object'); + expect(requestToServer.method).to.equal('POST'); + expect(requestToServer.url).to.equal(ENDPOINT); + expect(requestToServer.data).to.be.a('object'); + }); + + it('should build a correct ORTB request payload', function () { + const payload = requestToServer.data; + + expect(payload.id).to.be.a('string'); + expect(payload.imp).to.be.an('array').with.lengthOf(1); + expect(payload.cur).to.deep.equal(['USD']); + expect(payload.tmax).to.equal(bidderRequest.timeout); + + expect(payload.site).to.exist; + expect(payload.site.domain).to.equal('test.com'); + expect(payload.site.page).to.equal(bidderRequest.refererInfo.topmostLocation); + expect(payload.site.referrer).to.equal(bidderRequest.refererInfo.ref); + expect(payload.site.top).to.equal(true); + expect(payload.site.ext.data.pageType).to.equal('article'); + + expect(payload.device).to.exist; + expect(payload.device.language).to.include('en'); + expect(payload.device.dnt).to.equal(0); + expect(payload.device.js).to.equal(1); + expect(payload.device.w).to.equal(1920); + expect(payload.device.h).to.equal(1080); + expect(payload.device.ext.vpw).to.be.a('number'); + expect(payload.device.ext.vph).to.be.a('number'); + + expect(payload.regs).to.exist; + expect(payload.regs.gdpr).to.equal(1); + expect(payload.regs.coppa).to.equal(0); + expect(payload.regs.us_privacy).to.equal(bidderRequest.uspConsent); + expect(payload.regs.ext.gdpr_conset).to.equal(bidderRequest.gdprConsent.consentString); + expect(payload.regs.ext.gpp).to.equal(bidderRequest.ortb2.regs.gpp); + expect(payload.regs.ext.gppSid).to.deep.equal(bidderRequest.ortb2.regs.gpp_sid); + expect(payload.regs.ext.dsa).to.deep.equal(bidderRequest.ortb2.regs.ext.dsa); + + expect(payload.ext.params).to.deep.equal(validBidRequests[0].params); + + const imp = payload.imp[0]; + expect(imp.id).to.equal(validBidRequests[0].bidId); + expect(imp.banner).to.exist; + expect(imp.banner.format).to.be.an('array').with.lengthOf(2); + expect(imp.banner.format[0]).to.deep.equal({ w: 300, h: 250 }); + expect(imp.ext.data.adserver.name).to.equal('gam'); + expect(imp.ext.data.adserver.adslot).to.equal('/123/adunit'); + expect(imp.ext.data.pbadslot).to.equal('/123/adunit'); + expect(imp.ext.gpid).to.equal('/123/adunit'); + }); + + it('should include schain if present', function () { + let bidWithSchain = deepClone(validBidRequests); + bidWithSchain[0].schain = { ver: '1.0', complete: 1, nodes: [] }; + let reqWithSchain = deepClone(bidderRequest); + reqWithSchain.bids = bidWithSchain; + + const request = spec.buildRequests(bidWithSchain, reqWithSchain); + const payload = request[0].data; + expect(payload.source.ext.schain).to.deep.equal(bidWithSchain[0].schain); + }); + + it('should include eids if present', function () { + let bidWithEids = deepClone(validBidRequests); + bidWithEids[0].userIdAsEids = [{ source: 'pubcid.org', uids: [{ id: 'test-pubcid' }] }]; + let reqWithEids = deepClone(bidderRequest); + reqWithEids.bids = bidWithEids; + + const request = spec.buildRequests(bidWithEids, reqWithEids); + const payload = request[0].data; + expect(payload.user.ext.eids).to.deep.equal(bidWithEids[0].userIdAsEids); + }); + + it('should handle floors correctly', function () { + let bidWithFloor = deepClone(validBidRequests); + bidWithFloor[0].getFloor = sandbox.stub().returns({ currency: 'USD', floor: 1.50 }); + let reqWithFloor = deepClone(bidderRequest); + reqWithFloor.bids = bidWithFloor; + + const request = spec.buildRequests(bidWithFloor, reqWithFloor); + const payload = request[0].data; + expect(payload.imp[0].bidfloor).to.equal(1.50); + expect(payload.imp[0].bidfloorcur).to.equal('USD'); + sinon.assert.calledWith(bidWithFloor[0].getFloor, { currency: 'USD', mediaType: BANNER, size: [300, 250] }); + }); + }); + + describe('interpretResponse', function () { + let serverResponse; + + beforeEach(function() { + serverResponse = { + body: { + id: 'test-response-id', + seatbid: [ + { + seat: 'valuad', + bid: [ + { + id: 'test-bid-id', + impid: 'bid-id-1', + price: 1.50, + adm: '