Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
134 changes: 127 additions & 7 deletions modules/unicornBidAdapter.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
import { logInfo, deepAccess, generateUUID } from '../src/utils.js';
import { logInfo, logWarn, deepAccess, generateUUID, canAccessWindowTop, getWindowTop, getWindowSelf } from '../src/utils.js';
import { getWinDimensions } from '../src/utils/winDimensions.js';
import { BANNER } from '../src/mediaTypes.js';
import { registerBidder } from '../src/adapters/bidderFactory.js';
import { getStorageManager } from '../src/storageManager.js';
import { getGptSlotInfoForAdUnitCode } from '../libraries/gptUtils/gptUtils.js';
import { getBoundingBox, getViewportOffset, getViewability } from '../libraries/percentInView/percentInView.js';

/**
* @typedef {import('../src/adapters/bidderFactory.js').BidRequest} BidRequest
Expand All @@ -13,6 +16,7 @@ const UNICORN_ENDPOINT = 'https://ds.uncn.jp/pb/0/bid.json';
const UNICORN_DEFAULT_CURRENCY = 'JPY';
const UNICORN_PB_COOKIE_KEY = '__pb_unicorn_aud';
const UNICORN_PB_VERSION = '1.1';
const ADSLOT_SIGNAL_VERSION = 1; // imp.ext.unicorn schema version
const storage = getStorageManager({ bidderCode: BIDDER_CODE });

/**
Expand All @@ -37,6 +41,106 @@ export const buildRequests = (validBidRequests, bidderRequest) => {
};
};

/**
* Resolve the slot's DOM element id for a bid request, in priority order:
* 1) explicit ortb2Imp.ext.data.divId override (established convention,
* also read by adagioBidAdapter/adagioRtdProvider/contxtfulRtdProvider);
* 2) GPT slot mapping (getSlotElementId) — handles code !== div id;
* 3) the ad unit code itself (when div id === code).
*/
function resolveDivId(bidRequest) {
const override = deepAccess(bidRequest, 'ortb2Imp.ext.data.divId');
if (override) return override;
const fromGpt = getGptSlotInfoForAdUnitCode(bidRequest.adUnitCode).divId;
return fromGpt || bidRequest.adUnitCode;
}

/**
* Walk up the ancestor chain to detect a fixed/sticky wrapper. Anchor and
* sticky ad units are usually a `position: fixed`/`sticky` *wrapper* around a
* statically positioned ad div, so checking only the slot element itself
* misses the common case.
*/
function fixedOrSticky(el) {
const win = el.ownerDocument?.defaultView || window;
let node = el;
let fixed = false;
let sticky = false;
while (node && node.nodeType === 1) {
const position = win.getComputedStyle(node).position;
if (position === 'fixed') fixed = true;
else if (position === 'sticky') sticky = true;
node = node.parentElement;
}
return { fixed, sticky };
}

/**
* Measure the ad slot's on-screen position/geometry and viewability, for the
* imp this bid request builds. Returns null when the slot element cannot be
* resolved.
*
* This runs synchronously in buildRequests, when the auction is already
* dispatching bid requests to bidders — by that point slot elements are
* expected to be in the DOM, the same assumption other adapters that call
* `percentInView` synchronously (33across, marsmedia, oms, ...) rely on.
* Measuring here — rather than pre-auction in a Real-Time Data submodule —
* keeps the signal scoped to this adapter's own OpenRTB payload: nothing is
* written back to `ortb2Imp`, so no other bidder or PBS can be affected by it.
*/
function measureAdslot(bidRequest) {
const divId = resolveDivId(bidRequest);
const el = divId && document.getElementById(divId);
if (!el) {
logWarn(`[UNICORN] adslot element not found for adUnit "${bidRequest.adUnitCode}" (divId="${divId}")`);
return null;
}

const size = { w: deepAccess(bidRequest, 'sizes.0.0'), h: deepAccess(bidRequest, 'sizes.0.1') };
const win = el.ownerDocument.defaultView;

// getBoundingBox uses Prebid's shared (per-auction, cached) getBoundingClientRect
// helper and applies the size override when the element measures 0x0 (e.g. an
// empty GPT slot div before the creative renders).
const box = getBoundingBox(el, size);

// offset between this window's viewport and the top window's, for slots
// measured from inside a friendly iframe.
const offset = getViewportOffset(win);
const dims = getWinDimensions();
const scrollX = dims.document.documentElement.scrollLeft || dims.document.body.scrollLeft || 0;
const scrollY = dims.document.documentElement.scrollTop || dims.document.body.scrollTop || 0;
const x = Math.round(box.left + offset.x + scrollX);
const y = Math.round(box.top + offset.y + scrollY);

// "Above the fold" is a document-relative property — whether the slot
// falls within the page's *initial* viewport — so compare the document-relative
// y against the viewport height, not the (scroll-dependent) viewport-relative
// rect.top. This keeps `pos` stable across refresh auctions after the user
// has scrolled.
const vh = dims.document.documentElement.clientHeight;
const pos = y < vh ? 1 : 3; // OpenRTB AdPosition: 1 = above the fold, 3 = below the fold

const topWin = canAccessWindowTop() ? getWindowTop() : getWindowSelf();
const ratio = Number((getViewability(el, topWin, size) / 100).toFixed(2));

const { fixed, sticky } = fixedOrSticky(el);

return {
pos,
signal: {
ver: ADSLOT_SIGNAL_VERSION,
ratio,
fixed,
sticky,
w: Math.round(box.width),
h: Math.round(box.height),
x,
y
}
};
}

/**
* Transform BidRequest to OpenRTB-formatted BidRequest Object
* @param {Array<BidRequest>} validBidRequests
Expand All @@ -47,17 +151,33 @@ function buildOpenRtbBidRequestPayload(validBidRequests, bidderRequest) {
logInfo('[UNICORN] buildOpenRtbBidRequestPayload.validBidRequests:', validBidRequests);
logInfo('[UNICORN] buildOpenRtbBidRequestPayload.bidderRequest:', bidderRequest);
const imp = validBidRequests.map(br => {
return {
const banner = {
format: makeFormat(br.sizes),
w: br.sizes[0][0],
h: br.sizes[0][1]
};
const adslot = measureAdslot(br);
// A publisher-declared pos (via global FPD) takes priority over our own
// measurement.
const declaredPos = deepAccess(br, 'ortb2Imp.banner.pos');
if (declaredPos != null) {
banner.pos = declaredPos;
} else if (adslot) {
banner.pos = adslot.pos;
}
const impObj = {
id: br.bidId,
banner: {
format: makeFormat(br.sizes),
w: br.sizes[0][0],
h: br.sizes[0][1]
},
banner,
tagid: deepAccess(br, 'params.placementId') || br.adUnitCode,
secure: 1,
bidfloor: parseFloat(0)
};
if (adslot) {
// Slot geometry/viewability, sent only in this adapter's own OpenRTB
// payload — not shared FPD, so no other bidder or PBS ever sees it.
impObj.ext = { unicorn: adslot.signal };

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The name clashing is no longer a concern given that this is only seen by your exchange; I don't mind either way but I thought I'd note it in case unicorn means server side work that wouldn't be necessary with adslot.

}
return impObj;
});
const request = {
id: bidderRequest.bidderRequestId,
Expand Down
16 changes: 16 additions & 0 deletions modules/unicornBidAdapter.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,22 @@

Module that connects to UNICORN.

For each bid request, the adapter measures the ad slot's on-screen
position/geometry and viewability and sends it in the OpenRTB payload it
builds — this is scoped to this adapter's own request only, nothing is
written back to shared First Party Data:

- `imp.banner.pos` — OpenRTB AdPosition (1 = above the fold, 3 = below the
fold). A publisher-declared `ortb2Imp.banner.pos`, if present, is used
instead of the measured value.
- `imp.ext.unicorn` — `{ ver, ratio, fixed, sticky, w, h, x, y }`, where
`ratio` is the visible-area ratio (0–1), `fixed`/`sticky` report a
fixed/sticky ancestor, and `x`/`y`/`w`/`h` are the slot's document-relative
position and rendered size in CSS pixels.

Slot element resolution order: `ortb2Imp.ext.data.divId` → GPT
`getSlotElementId()` → the ad unit code.

# Test Parameters

```js
Expand Down
176 changes: 176 additions & 0 deletions test/spec/modules/unicornBidAdapter_spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,9 @@ import * as utils from 'src/utils.js';
import { spec } from 'modules/unicornBidAdapter.js';
import 'lodash';
import { getGlobal } from '../../../src/prebidGlobal.js';
import * as percentInViewLib from 'libraries/percentInView/percentInView.js';
import * as winDimensionsLib from 'src/utils/winDimensions.js';
import { clearSlotInfoCache } from 'libraries/gptUtils/gptUtils.js';

const bidRequests = [
{
Expand Down Expand Up @@ -567,4 +570,177 @@ describe('unicornBidAdapterTest', () => {
assert.deepStrictEqual(bids, []);
});
});

describe('adslot measurement', () => {
const VH = 800;
const createdEls = [];
let sandbox;
let origGoogletag;
let winDimensionsStub;

function makeSlotEl(id, rect, { position, parent } = {}) {
const el = document.createElement('div');
el.id = id;
if (position) el.style.position = position;
(parent || document.body).appendChild(el);
el.getBoundingClientRect = () => ({
top: rect.top,
left: rect.left,
right: rect.left + rect.width,
bottom: rect.top + rect.height,
width: rect.width,
height: rect.height
});
createdEls.push(el);
return el;
}

function bidReq(adUnitCode, overrides = {}) {
return Object.assign({
bidder: 'unicorn',
params: { accountId: 12345 },
mediaTypes: { banner: { sizes: [[300, 250]] } },
adUnitCode,
sizes: [[300, 250]],
bidId: 'bid-adslot',
bidderRequestId: 'bidder-req-adslot',
transactionId: 'tx-adslot',
auctionId: 'auction-adslot',
src: 'client'
}, overrides);
}

const bReq = {
bidderCode: 'unicorn',
auctionId: 'auction-adslot',
bidderRequestId: 'bidder-req-adslot',
refererInfo: {
ref: 'https://uni-corn.net/',
reachedTop: true,
numIframes: 0,
stack: ['https://uni-corn.net/']
}
};

function buildImp(br) {
const req = spec.buildRequests([br], bReq);
return JSON.parse(req.data).imp[0];
}

beforeEach(() => {
sandbox = sinon.createSandbox();
sandbox.stub(percentInViewLib, 'getViewportOffset').returns({ x: 0, y: 0 });
winDimensionsStub = sandbox.stub(winDimensionsLib, 'getWinDimensions').returns({
document: {
documentElement: { scrollLeft: 0, scrollTop: 0, clientHeight: VH },
body: { scrollLeft: 0, scrollTop: 0 }
}
});
origGoogletag = window.googletag;
});

afterEach(() => {
sandbox.restore();
window.googletag = origGoogletag;
clearSlotInfoCache();
createdEls.splice(0).forEach(el => el.remove());
});

it('adds imp.ext.unicorn and imp.banner.pos for a resolvable, above-the-fold slot', () => {
sandbox.stub(percentInViewLib, 'getViewability').returns(75);
makeSlotEl('adslot-1', { top: 100, left: 10, width: 300, height: 250 });
const imp = buildImp(bidReq('adslot-1'));

expect(imp.ext.unicorn).to.deep.equal({
ver: 1, ratio: 0.75, fixed: false, sticky: false, w: 300, h: 250, x: 10, y: 100
});
expect(imp.banner.pos).to.equal(1);
});

it('uses document-relative y, not viewport-relative rect.top, for the fold check', () => {
sandbox.stub(percentInViewLib, 'getViewability').returns(0);
// rect.top alone looks "above the fold", but a large scroll offset
// means the slot's real page position is well past one viewport height.
winDimensionsStub.returns({
document: {
documentElement: { scrollLeft: 0, scrollTop: 5000, clientHeight: VH },
body: { scrollLeft: 0, scrollTop: 0 }
}
});
makeSlotEl('adslot-2', { top: 50, left: 0, width: 300, height: 250 });
const imp = buildImp(bidReq('adslot-2'));

expect(imp.ext.unicorn.y).to.equal(5050);
expect(imp.banner.pos).to.equal(3);
});

it('applies the ad unit size override when the slot measures 0x0 (unrendered GPT slot)', () => {
sandbox.stub(percentInViewLib, 'getViewability').returns(40);
makeSlotEl('adslot-3', { top: 0, left: 0, width: 0, height: 0 });
const imp = buildImp(bidReq('adslot-3'));

expect(imp.ext.unicorn.w).to.equal(300);
expect(imp.ext.unicorn.h).to.equal(250);
expect(imp.ext.unicorn.ratio).to.equal(0.4);
});

it('detects a fixed ancestor wrapper, not only the slot element itself', () => {
sandbox.stub(percentInViewLib, 'getViewability').returns(100);
const wrapper = document.createElement('div');
wrapper.style.position = 'fixed';
document.body.appendChild(wrapper);
createdEls.push(wrapper);
makeSlotEl('adslot-4', { top: 0, left: 0, width: 300, height: 250 }, { parent: wrapper });
const imp = buildImp(bidReq('adslot-4'));

expect(imp.ext.unicorn.fixed).to.equal(true);
expect(imp.ext.unicorn.sticky).to.equal(false);
});

it('keeps fixed and sticky as distinct flags', () => {
sandbox.stub(percentInViewLib, 'getViewability').returns(100);
const wrapper = document.createElement('div');
wrapper.style.position = 'sticky';
document.body.appendChild(wrapper);
createdEls.push(wrapper);
makeSlotEl('adslot-5', { top: 0, left: 0, width: 300, height: 250 }, { parent: wrapper });
const imp = buildImp(bidReq('adslot-5'));

expect(imp.ext.unicorn.sticky).to.equal(true);
expect(imp.ext.unicorn.fixed).to.equal(false);
});

it('prefers a publisher-declared ortb2Imp.banner.pos over the measured value', () => {
sandbox.stub(percentInViewLib, 'getViewability').returns(0);
makeSlotEl('adslot-6', { top: 5000, left: 0, width: 300, height: 250 });
const br = bidReq('adslot-6', { ortb2Imp: { banner: { pos: 7 } } });
const imp = buildImp(br);

expect(imp.banner.pos).to.equal(7);
});

it('resolves the slot element via GPT slot mapping when adUnitCode differs from the div id', () => {
sandbox.stub(percentInViewLib, 'getViewability').returns(50);
makeSlotEl('gpt-mapped-div', { top: 0, left: 0, width: 300, height: 250 });
window.googletag = {
pubads: () => ({
getSlots: () => [{
getAdUnitPath: () => '/1234/gpt-ad-unit-code',
getSlotElementId: () => 'gpt-mapped-div'
}]
})
};
const imp = buildImp(bidReq('/1234/gpt-ad-unit-code'));

expect(imp.ext.unicorn).to.not.equal(undefined);
expect(imp.ext.unicorn.w).to.equal(300);
});

it('omits ext.unicorn and banner.pos when the slot element cannot be resolved', () => {
const imp = buildImp(bidReq('adslot-does-not-exist'));

expect(imp.ext).to.equal(undefined);
expect(imp.banner.pos).to.equal(undefined);
});
});
});
Loading