diff --git a/modules/unicornBidAdapter.js b/modules/unicornBidAdapter.js index 1fd3b2afcb..d8eb77e70b 100644 --- a/modules/unicornBidAdapter.js +++ b/modules/unicornBidAdapter.js @@ -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 @@ -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 }); /** @@ -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} validBidRequests @@ -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 }; + } + return impObj; }); const request = { id: bidderRequest.bidderRequestId, diff --git a/modules/unicornBidAdapter.md b/modules/unicornBidAdapter.md index 52c11aae3e..6383f370a7 100644 --- a/modules/unicornBidAdapter.md +++ b/modules/unicornBidAdapter.md @@ -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 diff --git a/test/spec/modules/unicornBidAdapter_spec.js b/test/spec/modules/unicornBidAdapter_spec.js index 01dd298a8f..7cf7cdd81f 100644 --- a/test/spec/modules/unicornBidAdapter_spec.js +++ b/test/spec/modules/unicornBidAdapter_spec.js @@ -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 = [ { @@ -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); + }); + }); });