Skip to content

Commit 2f8afb2

Browse files
css12sJoan Faurapatmmccann
authored
New Adapter: Panxo - AI traffic monetization SSP (prebid#14351)
* New Adapter: Panxo - AI traffic monetization SSP This PR adds a new bid adapter for Panxo, a specialized SSP for AI-referred traffic monetization. Features: - Banner media type support - GDPR/TCF 2.0 consent support - CCPA/US Privacy support - GPP support - COPPA compliance - Supply Chain (schain) support - First Party Data (ortb2) support - Prebid Floor Module support - Pixel-based user sync - Win notifications via nurl * fix: address PR review feedback - read schain from ortb2 and split requests by propertyKey * feat: add IAB TCF GVL ID 1527 Panxo is now registered in the IAB Global Vendor List with ID 1527. This enables proper TCF consent handling by Prebid.js. --------- Co-authored-by: Joan Faura <joanfaura@Ordenador-portatil-de-Joan.local> Co-authored-by: Patrick McCann <patmmccann@gmail.com>
1 parent 84a66a8 commit 2f8afb2

File tree

3 files changed

+852
-0
lines changed

3 files changed

+852
-0
lines changed

modules/panxoBidAdapter.js

Lines changed: 344 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,344 @@
1+
/**
2+
* @module panxoBidAdapter
3+
* @description Panxo Bid Adapter for Prebid.js - AI-referred traffic monetization
4+
* @requires Panxo Signal script (cdn.panxo-sys.com) loaded before Prebid
5+
*/
6+
7+
import { registerBidder } from '../src/adapters/bidderFactory.js';
8+
import { BANNER } from '../src/mediaTypes.js';
9+
import { deepAccess, logWarn, isFn, isPlainObject } from '../src/utils.js';
10+
import { getStorageManager } from '../src/storageManager.js';
11+
12+
const BIDDER_CODE = 'panxo';
13+
const ENDPOINT_URL = 'https://panxo-sys.com/openrtb/2.5/bid';
14+
const USER_ID_KEY = 'panxo_uid';
15+
const SYNC_URL = 'https://panxo-sys.com/usersync';
16+
const DEFAULT_CURRENCY = 'USD';
17+
const TTL = 300;
18+
const NET_REVENUE = true;
19+
20+
export const storage = getStorageManager({ bidderCode: BIDDER_CODE });
21+
22+
export function getPanxoUserId() {
23+
try {
24+
return storage.getDataFromLocalStorage(USER_ID_KEY);
25+
} catch (e) {
26+
// storageManager handles errors internally
27+
}
28+
return null;
29+
}
30+
31+
function buildBanner(bid) {
32+
const sizes = deepAccess(bid, 'mediaTypes.banner.sizes') || bid.sizes || [];
33+
if (sizes.length === 0) return null;
34+
35+
return {
36+
format: sizes.map(size => ({ w: size[0], h: size[1] })),
37+
w: sizes[0][0],
38+
h: sizes[0][1]
39+
};
40+
}
41+
42+
function getFloorPrice(bid, size) {
43+
if (isFn(bid.getFloor)) {
44+
try {
45+
const floorInfo = bid.getFloor({
46+
currency: DEFAULT_CURRENCY,
47+
mediaType: BANNER,
48+
size: size
49+
});
50+
if (floorInfo && floorInfo.floor) {
51+
return floorInfo.floor;
52+
}
53+
} catch (e) {
54+
// Floor module error
55+
}
56+
}
57+
return deepAccess(bid, 'params.floor') || 0;
58+
}
59+
60+
function buildUser(panxoUid, bidderRequest) {
61+
const user = { buyeruid: panxoUid };
62+
63+
// GDPR consent
64+
const gdprConsent = deepAccess(bidderRequest, 'gdprConsent');
65+
if (gdprConsent && gdprConsent.consentString) {
66+
user.ext = { consent: gdprConsent.consentString };
67+
}
68+
69+
// First Party Data - user
70+
const fpd = deepAccess(bidderRequest, 'ortb2.user');
71+
if (isPlainObject(fpd)) {
72+
user.ext = { ...user.ext, ...fpd.ext };
73+
if (fpd.data) user.data = fpd.data;
74+
}
75+
76+
return user;
77+
}
78+
79+
function buildRegs(bidderRequest) {
80+
const regs = { ext: {} };
81+
82+
// GDPR
83+
const gdprConsent = deepAccess(bidderRequest, 'gdprConsent');
84+
if (gdprConsent) {
85+
regs.ext.gdpr = gdprConsent.gdprApplies ? 1 : 0;
86+
}
87+
88+
// CCPA / US Privacy
89+
const uspConsent = deepAccess(bidderRequest, 'uspConsent');
90+
if (uspConsent) {
91+
regs.ext.us_privacy = uspConsent;
92+
}
93+
94+
// GPP
95+
const gppConsent = deepAccess(bidderRequest, 'gppConsent');
96+
if (gppConsent) {
97+
regs.ext.gpp = gppConsent.gppString;
98+
regs.ext.gpp_sid = gppConsent.applicableSections;
99+
}
100+
101+
// COPPA
102+
const coppa = deepAccess(bidderRequest, 'ortb2.regs.coppa');
103+
if (coppa) {
104+
regs.coppa = 1;
105+
}
106+
107+
return regs;
108+
}
109+
110+
function buildDevice() {
111+
const device = {
112+
ua: navigator.userAgent,
113+
language: navigator.language,
114+
js: 1,
115+
dnt: navigator.doNotTrack === '1' ? 1 : 0
116+
};
117+
118+
if (typeof screen !== 'undefined') {
119+
device.w = screen.width;
120+
device.h = screen.height;
121+
}
122+
123+
return device;
124+
}
125+
126+
function buildSite(bidderRequest) {
127+
const site = {
128+
page: deepAccess(bidderRequest, 'refererInfo.page') || '',
129+
domain: deepAccess(bidderRequest, 'refererInfo.domain') || '',
130+
ref: deepAccess(bidderRequest, 'refererInfo.ref') || ''
131+
};
132+
133+
// First Party Data - site
134+
const fpd = deepAccess(bidderRequest, 'ortb2.site');
135+
if (isPlainObject(fpd)) {
136+
Object.assign(site, {
137+
name: fpd.name,
138+
cat: fpd.cat,
139+
sectioncat: fpd.sectioncat,
140+
pagecat: fpd.pagecat,
141+
content: fpd.content
142+
});
143+
if (fpd.ext) site.ext = fpd.ext;
144+
}
145+
146+
return site;
147+
}
148+
149+
function buildSource(bidderRequest) {
150+
const source = {
151+
tid: deepAccess(bidderRequest, 'ortb2.source.tid') || bidderRequest.auctionId
152+
};
153+
154+
// Supply Chain (schain) - read from ortb2 where Prebid normalizes it
155+
const schain = deepAccess(bidderRequest, 'ortb2.source.ext.schain');
156+
if (isPlainObject(schain)) {
157+
source.ext = { schain: schain };
158+
}
159+
160+
return source;
161+
}
162+
163+
export const spec = {
164+
code: BIDDER_CODE,
165+
gvlid: 1527,
166+
supportedMediaTypes: [BANNER],
167+
168+
isBidRequestValid(bid) {
169+
const propertyKey = deepAccess(bid, 'params.propertyKey');
170+
if (!propertyKey) {
171+
logWarn('Panxo: Missing required param "propertyKey"');
172+
return false;
173+
}
174+
if (!deepAccess(bid, 'mediaTypes.banner')) {
175+
logWarn('Panxo: Only banner mediaType is supported');
176+
return false;
177+
}
178+
return true;
179+
},
180+
181+
buildRequests(validBidRequests, bidderRequest) {
182+
const panxoUid = getPanxoUserId();
183+
if (!panxoUid) {
184+
logWarn('Panxo: panxo_uid not found. Ensure Signal script is loaded before Prebid.');
185+
return [];
186+
}
187+
188+
// Group bids by propertyKey to handle multiple properties on same page
189+
const bidsByPropertyKey = {};
190+
validBidRequests.forEach(bid => {
191+
const key = deepAccess(bid, 'params.propertyKey');
192+
if (!bidsByPropertyKey[key]) {
193+
bidsByPropertyKey[key] = [];
194+
}
195+
bidsByPropertyKey[key].push(bid);
196+
});
197+
198+
// Build one request per propertyKey
199+
const requests = [];
200+
Object.keys(bidsByPropertyKey).forEach(propertyKey => {
201+
const bidsForKey = bidsByPropertyKey[propertyKey];
202+
203+
const impressions = bidsForKey.map((bid) => {
204+
const banner = buildBanner(bid);
205+
if (!banner) return null;
206+
207+
const sizes = deepAccess(bid, 'mediaTypes.banner.sizes') || [];
208+
const primarySize = sizes[0] || [300, 250];
209+
210+
// Include ortb2Imp if available
211+
const ortb2Imp = deepAccess(bid, 'ortb2Imp.ext');
212+
213+
return {
214+
id: bid.bidId,
215+
banner: banner,
216+
bidfloor: getFloorPrice(bid, primarySize),
217+
bidfloorcur: DEFAULT_CURRENCY,
218+
secure: 1,
219+
tagid: bid.adUnitCode,
220+
ext: ortb2Imp || undefined
221+
};
222+
}).filter(Boolean);
223+
224+
if (impressions.length === 0) return;
225+
226+
const openrtbRequest = {
227+
id: bidderRequest.bidderRequestId,
228+
imp: impressions,
229+
site: buildSite(bidderRequest),
230+
device: buildDevice(),
231+
user: buildUser(panxoUid, bidderRequest),
232+
regs: buildRegs(bidderRequest),
233+
source: buildSource(bidderRequest),
234+
at: 1,
235+
cur: [DEFAULT_CURRENCY],
236+
tmax: bidderRequest.timeout || 1000
237+
};
238+
239+
requests.push({
240+
method: 'POST',
241+
url: `${ENDPOINT_URL}?key=${encodeURIComponent(propertyKey)}&source=prebid`,
242+
data: openrtbRequest,
243+
options: { contentType: 'application/json', withCredentials: false },
244+
bidderRequest: bidderRequest
245+
});
246+
});
247+
248+
return requests;
249+
},
250+
251+
interpretResponse(serverResponse, request) {
252+
const bids = [];
253+
const response = serverResponse.body;
254+
255+
if (!response || !response.seatbid) return bids;
256+
257+
const bidRequestMap = {};
258+
if (request.bidderRequest && request.bidderRequest.bids) {
259+
request.bidderRequest.bids.forEach(bid => {
260+
bidRequestMap[bid.bidId] = bid;
261+
});
262+
}
263+
264+
response.seatbid.forEach(seatbid => {
265+
if (!seatbid.bid) return;
266+
267+
seatbid.bid.forEach(bid => {
268+
const originalBid = bidRequestMap[bid.impid];
269+
if (!originalBid) return;
270+
271+
bids.push({
272+
requestId: bid.impid,
273+
cpm: bid.price,
274+
currency: response.cur || DEFAULT_CURRENCY,
275+
width: bid.w,
276+
height: bid.h,
277+
creativeId: bid.crid || bid.id,
278+
dealId: bid.dealid || null,
279+
netRevenue: NET_REVENUE,
280+
ttl: bid.exp || TTL,
281+
ad: bid.adm,
282+
nurl: bid.nurl,
283+
meta: {
284+
advertiserDomains: bid.adomain || [],
285+
mediaType: BANNER
286+
}
287+
});
288+
});
289+
});
290+
291+
return bids;
292+
},
293+
294+
getUserSyncs(syncOptions, serverResponses, gdprConsent, uspConsent, gppConsent) {
295+
const syncs = [];
296+
297+
if (syncOptions.pixelEnabled) {
298+
let syncUrl = SYNC_URL + '?source=prebid';
299+
300+
// GDPR
301+
if (gdprConsent) {
302+
syncUrl += `&gdpr=${gdprConsent.gdprApplies ? 1 : 0}`;
303+
if (gdprConsent.consentString) {
304+
syncUrl += `&gdpr_consent=${encodeURIComponent(gdprConsent.consentString)}`;
305+
}
306+
}
307+
308+
// US Privacy
309+
if (uspConsent) {
310+
syncUrl += `&us_privacy=${encodeURIComponent(uspConsent)}`;
311+
}
312+
313+
// GPP
314+
if (gppConsent) {
315+
if (gppConsent.gppString) {
316+
syncUrl += `&gpp=${encodeURIComponent(gppConsent.gppString)}`;
317+
}
318+
if (gppConsent.applicableSections) {
319+
syncUrl += `&gpp_sid=${encodeURIComponent(gppConsent.applicableSections.join(','))}`;
320+
}
321+
}
322+
323+
syncs.push({ type: 'image', url: syncUrl });
324+
}
325+
326+
return syncs;
327+
},
328+
329+
onBidWon(bid) {
330+
if (bid.nurl) {
331+
const winUrl = bid.nurl.replace(/\$\{AUCTION_PRICE\}/g, bid.cpm);
332+
const img = document.createElement('img');
333+
img.src = winUrl;
334+
img.style.display = 'none';
335+
document.body.appendChild(img);
336+
}
337+
},
338+
339+
onTimeout(timeoutData) {
340+
logWarn('Panxo: Bid timeout', timeoutData);
341+
}
342+
};
343+
344+
registerBidder(spec);

0 commit comments

Comments
 (0)