diff --git a/modules/tpcBidAdapter.js b/modules/tpcBidAdapter.js new file mode 100644 index 0000000000..502f254b34 --- /dev/null +++ b/modules/tpcBidAdapter.js @@ -0,0 +1,148 @@ +import { ortbConverter } from '../libraries/ortbConverter/converter.js'; +import { pbsExtensions } from '../libraries/pbsExtensions/pbsExtensions.js'; +import { registerBidder } from '../src/adapters/bidderFactory.js'; +import { BANNER, VIDEO, NATIVE } from '../src/mediaTypes.js'; +import { deepAccess, deepSetValue, deepClone, logWarn, logError, triggerPixel, isEmpty, shuffle } from '../src/utils.js'; + +const BIDDER_CODE = 'tpc'; +const PBS_ENDPOINT = 'https://pbs.tpcsrv.com/openrtb2/auction'; +const USER_SYNC_ENDPOINT = 'https://pbs.tpcsrv.com/cookie_sync'; +const MAX_SYNC_COUNT = 10; + +const converter = ortbConverter({ + processors: pbsExtensions, + context: { + netRevenue: true, + ttl: 300, + }, + imp(buildImp, bidRequest, context) { + const imp = buildImp(bidRequest, context); + const { placementId, bidder } = bidRequest.params; + if (placementId) { + deepSetValue(imp, 'ext.prebid.bidder.tpc.placementId', placementId); + } + if (bidder) { + deepSetValue(imp, 'ext.prebid.bidder.tpc.bidder', bidder); + } + return imp; + }, + overrides: { + bidResponse: { + bidderCode(orig, bidResponse, bid, { bidRequest }) { + const useSourceBidderCode = deepAccess(bidRequest, 'params.useSourceBidderCode', false); + if (useSourceBidderCode) { + orig.apply(this, [...arguments].slice(1)); + } + }, + }, + }, +}); + +export const spec = { + code: BIDDER_CODE, + supportedMediaTypes: [BANNER, VIDEO, NATIVE], + aliases: [], + + isBidRequestValid(bid) { + if (!deepAccess(bid, 'params.accountId')) { + logWarn(`${BIDDER_CODE}: bid missing required params.accountId`, bid); + return false; + } + return true; + }, + + buildRequests(validBidRequests, bidderRequest) { + const { bidder } = validBidRequests[0]; + const data = converter.toORTB({ bidRequests: validBidRequests, bidderRequest }); + const accountId = deepAccess(validBidRequests[0], 'params.accountId'); + if (accountId) { + deepSetValue(data, 'site.publisher.id', accountId); + } + data.ext.prebid.passthrough = { + ...data.ext.prebid.passthrough, + tpc: { bidder }, + }; + data.tmax = (bidderRequest.timeout || 1500) - 100; + return { + method: 'POST', + url: PBS_ENDPOINT, + data, + }; + }, + + interpretResponse(serverResponse, request) { + if (!serverResponse?.body) return []; + const resp = deepClone(serverResponse.body); + const { bidder } = request.data.ext.prebid.passthrough.tpc; + const modifiers = { + responsetimemillis: (values) => Math.max(...values), + errors: (values) => [].concat(...values), + }; + Object.entries(modifiers).forEach(([field, combineFn]) => { + const obj = resp.ext?.[field]; + if (!isEmpty(obj)) { + resp.ext[field] = { [bidder]: combineFn(Object.values(obj)) }; + } + }); + return converter.fromORTB({ response: resp, request: request.data }).bids; + }, + + getUserSyncs(syncOptions, serverResponses, gdprConsent, uspConsent, gppConsent) { + if (!syncOptions.iframeEnabled && !syncOptions.pixelEnabled) return []; + + let bidders = []; + serverResponses.forEach(({ body }) => { + Object.keys(body?.ext?.responsetimemillis || {}).forEach(b => { + if (!bidders.includes(b)) bidders.push(b); + }); + }); + + if (!bidders.length) return []; + + bidders = shuffle(bidders).slice(0, MAX_SYNC_COUNT); + + const params = new URLSearchParams(); + params.set('bidders', bidders.join(',')); + params.set('max_sync_count', MAX_SYNC_COUNT); + + if (gdprConsent) { + params.set('gdpr', gdprConsent.gdprApplies ? '1' : '0'); + if (gdprConsent.consentString) { + params.set('gdpr_consent', gdprConsent.consentString); + } + } + if (uspConsent) { + params.set('us_privacy', uspConsent); + } + if (gppConsent?.gppString) { + params.set('gpp', gppConsent.gppString); + } + if (Array.isArray(gppConsent?.applicableSections)) { + params.set('gpp_sid', gppConsent.applicableSections.join(',')); + } + + const syncUrl = `${USER_SYNC_ENDPOINT}?${params.toString()}`; + if (syncOptions.iframeEnabled) { + return [{ type: 'iframe', url: syncUrl }]; + } + return [{ type: 'image', url: syncUrl }]; + }, + + onBidWon(bid) { + if (bid.pbsWurl) triggerPixel(bid.pbsWurl); + if (bid.burl) triggerPixel(bid.burl); + }, + + onBidderError({ error }) { + if (error.status === 400 && error.responseText) { + const match = error.responseText.match(/found for id: (.*)/); + if (match?.[1]) { + logError(`${BIDDER_CODE}: account '${match[1]}' not found. Please verify your accountId.`, error); + return; + } + } + logError(`${BIDDER_CODE} bidder error`, error); + }, +}; + +registerBidder(spec); diff --git a/modules/tpcBidAdapter.md b/modules/tpcBidAdapter.md new file mode 100644 index 0000000000..3f81ea4e88 --- /dev/null +++ b/modules/tpcBidAdapter.md @@ -0,0 +1,157 @@ +# Overview + +``` +Module Name: TPC Bid Adapter +Module Type: Bidder Adapter +Maintainer: your-team@tpcsrv.com +``` + +Connects to the TPC Prebid Server at `pbs.tpcsrv.com` for header bidding. +Supports **banner**, **video** (instream & outstream), and **native** ad formats. + +--- + +## Consent & Privacy Support + +| Signal | Support | +|--------|---------| +| TCF / GDPR | ✅ Passed in `regs.ext.gdpr` + `user.ext.consent` | +| US Privacy (legacy) | ✅ Passed in `regs.ext.us_privacy` | +| GPP (Global Privacy Platform) | ✅ Passed in `regs.gpp` + `regs.gpp_sid` | +| COPPA | ✅ Reads `pbjs.setConfig({ coppa: true })` | + +--- + +## User ID & Identity + +Any EIDs collected by the Prebid.js **User ID module** are forwarded to Prebid Server in `user.eids`. + +--- + +## User Syncing + +After each auction the adapter triggers a single **iframe** (preferred) or **pixel** sync with the +PBS `/cookie_sync` endpoint. Bidder codes are derived automatically from the auction response, so +PBS syncs only the seats that actually responded. + +--- + +## Bid Params + +| Name | Scope | Type | Description | +|------|-------|------|-------------| +| `accountId` | **Required** | string | Publisher account ID on pbs.tpcsrv.com | +| `placementId` | Optional | string | Placement / tag identifier | +| `bidder` | Optional | string | Downstream PBS bidder code for single-bidder passthrough | + +--- + +## Ad Unit Examples + +### Banner + +```js +var adUnits = [{ + code: 'banner-div', + mediaTypes: { + banner: { sizes: [[300, 250], [728, 90]] }, + }, + bids: [{ + bidder: 'tpc', + params: { + accountId: 'pub-1234', + placementId: 'homepage-leaderboard', + }, + }], +}]; +``` + +### Video (instream) + +```js +var adUnits = [{ + code: 'video-div', + mediaTypes: { + video: { + playerSize: [[640, 480]], + context: 'instream', + mimes: ['video/mp4'], + protocols: [1, 2, 3, 4, 5, 6], + playbackmethod: [2], + skip: 1, + }, + }, + bids: [{ + bidder: 'tpc', + params: { + accountId: 'pub-1234', + placementId: 'pre-roll-640', + }, + }], +}]; +``` + +### Native + +```js +var adUnits = [{ + code: 'native-div', + mediaTypes: { + native: { + ortb: { + assets: [ + { id: 1, required: 1, title: { len: 80 } }, + { id: 2, required: 1, img: { type: 3, w: 300, h: 250 } }, + { id: 3, required: 0, data: { type: 2 } }, // description + ], + }, + }, + }, + bids: [{ + bidder: 'tpc', + params: { + accountId: 'pub-1234', + }, + }], +}]; +``` + +### Multi-format + +```js +var adUnits = [{ + code: 'multi-div', + mediaTypes: { + banner: { sizes: [[300, 250]] }, + video: { + playerSize: [[300, 250]], + context: 'outstream', + mimes: ['video/mp4'], + protocols: [1, 2, 3, 4], + }, + }, + bids: [{ + bidder: 'tpc', + params: { + accountId: 'pub-1234', + placementId: 'sidebar-multi', + }, + }], +}]; +``` + +--- + +## Full Example Setup + +```js +pbjs.que.push(function () { + pbjs.addAdUnits(adUnits); + + pbjs.requestBids({ + bidsBackHandler: function (bids) { + // ... send targeting to ad server + }, + }); +}); +``` diff --git a/test/spec/modules/tpcBidAdapter_spec.js b/test/spec/modules/tpcBidAdapter_spec.js new file mode 100644 index 0000000000..69371b2999 --- /dev/null +++ b/test/spec/modules/tpcBidAdapter_spec.js @@ -0,0 +1,280 @@ +import { spec } from 'modules/tpcBidAdapter.js'; +import { parseUrl } from 'src/utils.js'; + +const expect = require('chai').expect; + +const PBS_HOST = 'pbs.tpcsrv.com'; +const ACCOUNT_ID = 'pub-1234'; +const PLACEMENT_ID = 'homepage-leaderboard'; +const TEST_DOMAIN = 'example.com'; +const TEST_PAGE = `https://${TEST_DOMAIN}/page.html`; +const ADUNIT_CODE = '/1234/header-bid-tag-0'; + +const BID_PARAMS = { + params: { + accountId: ACCOUNT_ID, + placementId: PLACEMENT_ID, + } +}; + +const BID_REQUEST = { + bidder: 'tpc', + ...BID_PARAMS, + ortb2Imp: { + ext: { + tid: 'e13391ea-00f3-495d-99a6-d937990d73a9' + } + }, + mediaTypes: { + banner: { + sizes: [ + [300, 250] + ] + } + }, + adUnitCode: ADUNIT_CODE, + transactionId: 'e13391ea-00f3-495d-99a6-d937990d73a9', + sizes: [[300, 250]], + bidId: '123456789', + bidderRequestId: '1decd098c76ed2', + auctionId: '251a6a36-a5c5-4b82-b2b3-538c148a29dd', + src: 'client', + bidRequestsCount: 1, + bidderRequestsCount: 1, + bidderWinsCount: 0, + ortb2: { + site: { + page: TEST_PAGE, + domain: TEST_DOMAIN, + publisher: { + domain: TEST_DOMAIN + } + }, + device: { + w: 1848, + h: 1007, + dnt: 0, + ua: 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/111.0.0.0 Safari/537.36', + language: 'en', + } + } +}; + +const BIDDER_REQUEST = { + bidderCode: BID_REQUEST.bidder, + auctionId: BID_REQUEST.auctionId, + bidderRequestId: BID_REQUEST.bidderRequestId, + bids: [BID_REQUEST], + ortb2: BID_REQUEST.ortb2, + auctionStart: 1681224591370, + timeout: 1000, + refererInfo: { + reachedTop: true, + isAmp: false, + numIframes: 0, + stack: [TEST_PAGE], + topmostLocation: TEST_PAGE, + location: TEST_PAGE, + canonicalUrl: null, + page: TEST_PAGE, + domain: TEST_DOMAIN, + ref: null, + }, + start: 1681224591375 +}; + +const BID_RESPONSE = { + seatbid: [ + { + bid: [ + { + id: '123456789', + impid: BID_REQUEST.bidId, + price: 1.5, + adm: '', + adomain: ['example.com'], + crid: 'creative-abc-123', + w: 300, + h: 250, + exp: 300, + mtype: 1, + ext: { + prebid: { + type: 'banner', + targeting: { + hb_size: '300x250', + hb_bidder: 'tpc', + hb_pb: '1.50' + }, + meta: { + advertiserDomains: ['example.com'] + } + }, + origbidcpm: 1.5 + } + } + ], + seat: 'appnexus', + group: 0 + } + ], + cur: 'USD', + ext: { + responsetimemillis: { + appnexus: 50 + }, + tmaxrequest: 900, + prebid: { + auctiontimestamp: 1678646619765, + passthrough: { + tpc: { + bidder: spec.code + } + } + } + } +}; + +const S2S_RESPONSE_BIDDER = BID_RESPONSE.seatbid[0].seat; + +const buildRequest = (params) => { + const bidRequest = { + ...BID_REQUEST, + params: { + ...BID_REQUEST.params, + ...params, + }, + }; + return spec.buildRequests([bidRequest], BIDDER_REQUEST); +}; + +describe('TPC Bid Adapter', function () { + describe('isBidRequestValid', () => { + it('should return true for a valid bid with accountId', () => { + expect(spec.isBidRequestValid(BID_REQUEST)).to.be.true; + }); + it('should return false when accountId is missing', () => { + const bid = { ...BID_REQUEST, params: {} }; + expect(spec.isBidRequestValid(bid)).to.be.false; + }); + }); + + describe('buildRequests', () => { + const { data, url } = buildRequest(); + it('should POST to the correct PBS endpoint', () => { + expect(url).equal(`https://${PBS_HOST}/openrtb2/auction`); + }); + it('should set site.publisher.id to the accountId', () => { + expect(data.site.publisher.id).equal(ACCOUNT_ID); + }); + it('should include bidder code in the passthrough object', () => { + expect(data.ext.prebid.passthrough.tpc.bidder).equal(spec.code); + }); + it('should set tmax below the bidder timeout', () => { + expect(data.tmax).be.greaterThan(0); + expect(data.tmax).be.lessThan(BIDDER_REQUEST.timeout); + }); + it('should set placementId in imp.ext.prebid.bidder.tpc', () => { + expect(data.imp[0].ext.prebid.bidder.tpc.placementId).equal(PLACEMENT_ID); + }); + }); + + describe('buildRequests without placementId', () => { + const { data } = buildRequest({ placementId: undefined }); + it('should not set placementId when not provided', () => { + expect(data.imp[0].ext?.prebid?.bidder?.tpc?.placementId).to.be.undefined; + }); + }); + + describe('interpretResponse', () => { + const request = buildRequest(); + const [bid] = spec.interpretResponse({ body: BID_RESPONSE }, request); + it('should return the correct bid values', () => { + const respBid = BID_RESPONSE.seatbid[0].bid[0]; + expect(bid.cpm).equal(respBid.price); + expect(bid.ad).equal(respBid.adm); + expect(bid.width).equal(respBid.w); + expect(bid.height).equal(respBid.h); + }); + it('should not expose the S2S seat as bidderCode by default', () => { + expect(bid.bidderCode).not.equal(S2S_RESPONSE_BIDDER); + }); + it('should return an empty array when there is no body', () => { + expect(spec.interpretResponse({}, request)).to.deep.equal([]); + }); + }); + + describe('interpretResponse with useSourceBidderCode', () => { + const request = buildRequest({ useSourceBidderCode: true }); + const [bid] = spec.interpretResponse({ body: BID_RESPONSE }, request); + it('should expose the S2S seat as bidderCode', () => { + expect(bid.bidderCode).equal(S2S_RESPONSE_BIDDER); + }); + }); + + describe('getUserSyncs with iframeEnabled', () => { + const allSyncs = spec.getUserSyncs({ iframeEnabled: true }, [{ body: BID_RESPONSE }], null, null, null); + const [{ url, type }] = allSyncs; + const parsed = parseUrl(url); + it('should return a single sync object', () => { + expect(allSyncs.length).equal(1); + }); + it('should use iframe sync type', () => { + expect(type).equal('iframe'); + }); + it('should sync to the cookie_sync endpoint', () => { + expect(parsed.hostname).equal(PBS_HOST); + expect(parsed.pathname).equal('/cookie_sync'); + }); + it('should include at least one bidder', () => { + expect(parsed.search.bidders.split(',').length).be.greaterThan(0); + }); + }); + + describe('getUserSyncs with pixelEnabled only', () => { + const allSyncs = spec.getUserSyncs({ iframeEnabled: false, pixelEnabled: true }, [{ body: BID_RESPONSE }], null, null, null); + it('should return a pixel sync when iframe is not enabled', () => { + expect(allSyncs.length).equal(1); + expect(allSyncs[0].type).equal('image'); + }); + }); + + describe('getUserSyncs with no sync options enabled', () => { + const allSyncs = spec.getUserSyncs({ iframeEnabled: false, pixelEnabled: false }, [{ body: BID_RESPONSE }], null, null, null); + it('should return an empty array', () => { + expect(allSyncs).to.deep.equal([]); + }); + }); + + describe('getUserSyncs with no bidders in response', () => { + const allSyncs = spec.getUserSyncs({ iframeEnabled: true }, [{ body: {} }], null, null, null); + it('should return an empty array when no bidders responded', () => { + expect(allSyncs).to.deep.equal([]); + }); + }); + + describe('getUserSyncs with consent signals', () => { + const gdprConsent = { gdprApplies: true, consentString: 'abc123' }; + const uspConsent = '1YNN'; + const gppConsent = { gppString: 'gpp_str', applicableSections: [7, 8] }; + const [{ url }] = spec.getUserSyncs( + { iframeEnabled: true }, + [{ body: BID_RESPONSE }], + gdprConsent, + uspConsent, + gppConsent + ); + const { search } = parseUrl(url); + it('should include GDPR params', () => { + expect(search.gdpr).equal('1'); + expect(search.gdpr_consent).equal('abc123'); + }); + it('should include US Privacy param', () => { + expect(search.us_privacy).equal('1YNN'); + }); + it('should include GPP params', () => { + expect(search.gpp).equal('gpp_str'); + expect(search.gpp_sid).equal('7,8'); + }); + }); +});