From 64f5e7aba8b30deccbc5b4686b487ce029d045c9 Mon Sep 17 00:00:00 2001 From: rufiange Date: Wed, 19 Feb 2025 15:45:56 -0500 Subject: [PATCH 1/4] feat: adunitpos --- modules/contxtfulRtdProvider.js | 166 +++++++++ .../spec/modules/contxtfulRtdProvider_spec.js | 324 +++++++++++++++++- 2 files changed, 485 insertions(+), 5 deletions(-) diff --git a/modules/contxtfulRtdProvider.js b/modules/contxtfulRtdProvider.js index 1bd977afed1..2c7f478d8c9 100644 --- a/modules/contxtfulRtdProvider.js +++ b/modules/contxtfulRtdProvider.js @@ -16,10 +16,18 @@ import { buildUrl, isArray, generateUUID, + canAccessWindowTop, + deepAccess, + getSafeframeGeometry, + getWindowSelf, + getWindowTop, + inIframe, + isSafeFrameWindow, } from '../src/utils.js'; import { loadExternalScript } from '../src/adloader.js'; import { getStorageManager } from '../src/storageManager.js'; import { MODULE_TYPE_RTD } from '../src/activities/modules.js'; +import { getGptSlotInfoForAdUnitCode } from '../libraries/gptUtils/gptUtils.js'; const MODULE_NAME = 'contxtful'; const MODULE = `${MODULE_NAME}RtdProvider`; @@ -225,6 +233,162 @@ function getTargetingData(adUnits, config, _userConsent) { } } +function getVisibilityStateElement(domElement, windowTop) { + if ('checkVisibility' in domElement) { + return domElement.checkVisibility(); + } + + const elementCss = windowTop.getComputedStyle(domElement, null); + return elementCss.display !== 'none'; +} + +function getElementFromTopWindowRecurs(element, currentWindow) { + try { + if (getWindowTop() === currentWindow) { + return element; + } else { + const frame = currentWindow.frameElement; + const frameClientRect = frame.getBoundingClientRect(); + const elementClientRect = element.getBoundingClientRect(); + if (frameClientRect.width !== elementClientRect.width || frameClientRect.height !== elementClientRect.height) { + return undefined; + } + return getElementFromTopWindowRecurs(frame, currentWindow.parent); + } + } catch (err) { + logError(MODULE, err); + return undefined; + } +} + +function getDivIdPosition(divId) { + if (!isSafeFrameWindow() && !canAccessWindowTop()) { + return {}; + } + + const position = {}; + + if (isSafeFrameWindow()) { + const { self } = getSafeframeGeometry() ?? {}; + + if (!self) { + return {}; + } + + position.x = Math.round(self.t); + position.y = Math.round(self.l); + } else { + try { + // window.top based computing + const wt = getWindowTop(); + const d = wt.document; + + let domElement; + + if (inIframe() === true) { + const ws = getWindowSelf(); + const currentElement = ws.document.getElementById(divId); + domElement = getElementFromTopWindowRecurs(currentElement, ws); + } else { + domElement = wt.document.getElementById(divId); + } + + if (!domElement) { + return {}; + } + + let box = domElement.getBoundingClientRect(); + const docEl = d.documentElement; + const body = d.body; + const clientTop = (d.clientTop ?? body.clientTop) ?? 0; + const clientLeft = (d.clientLeft ?? body.clientLeft) ?? 0; + const scrollTop = (wt.scrollY ?? docEl.scrollTop) ?? body.scrollTop; + const scrollLeft = (wt.scrollX ?? docEl.scrollLeft) ?? body.scrollLeft; + + position.visibility = getVisibilityStateElement(domElement, wt); + position.x = Math.round(box.left + scrollLeft - clientLeft); + position.y = Math.round(box.top + scrollTop - clientTop); + } catch (err) { + logError(MODULE, err); + return {}; + } + } + + return position; +} + +function tryGetDivIdPosition(divIdMethod) { + let divId = divIdMethod(); + if (divId) { + const divIdPosition = getDivIdPosition(divId); + if (divIdPosition.x !== undefined && divIdPosition.y !== undefined) { + return divIdPosition; + } + } + return undefined; +} + +function tryMultipleDivIdPositions(adUnit) { + let divMethods = [ + // ortb2\ + () => { + adUnit.ortb2Imp = adUnit.ortb2Imp || {}; + const ortb2Imp = deepAccess(adUnit, 'ortb2Imp'); + return deepAccess(ortb2Imp, 'ext.data.divId'); + }, + // gpt + () => getGptSlotInfoForAdUnitCode(adUnit.code).divId, + // adunit code + () => adUnit.code + ]; + + for (const divMethod of divMethods) { + let divPosition = tryGetDivIdPosition(divMethod); + if (divPosition) { + return divPosition; + } + } +} + +function tryGetAdUnitPosition(adUnit) { + let adUnitPosition = {}; + adUnit.ortb2Imp = adUnit.ortb2Imp || {}; + const ortb2Imp = deepAccess(adUnit, 'ortb2Imp'); + + // try to get position with the divId + const divIdPosition = tryMultipleDivIdPositions(adUnit); + if (divIdPosition) { + adUnitPosition.p = { x: divIdPosition.x, y: divIdPosition.y }; + adUnitPosition.v = divIdPosition.visibility; + adUnitPosition.t = 'div'; + return adUnitPosition; + } + + // try to get IAB position + const iabPos = adUnit?.mediaTypes?.banner?.pos; + if (iabPos !== undefined) { + adUnitPosition.p = iabPos; + adUnitPosition.t = 'iab'; + return adUnitPosition; + } + + return undefined; +} + +function getAdUnitPositions(bidReqConfig) { + const adUnits = bidReqConfig.adUnits || []; + let adUnitPositions = {}; + + for (const adUnit of adUnits) { + let adUnitPosition = tryGetAdUnitPosition(adUnit); + if (adUnitPosition) { + adUnitPositions[adUnit.code] = adUnitPosition; + } + } + + return adUnitPositions; +} + /** * @param {Object} reqBidsConfigObj Bid request configuration object * @param {Function} onDone Called on completion @@ -237,6 +401,7 @@ function getBidRequestData(reqBidsConfigObj, onDone, config, userConsent) { } logInfo(MODULE, 'getBidRequestData'); + const adUnitsPositions = getAdUnitPositions(reqBidsConfigObj); const bidders = config?.params?.bidders || []; if (isEmpty(bidders) || !isArray(bidders)) { onReturn(); @@ -262,6 +427,7 @@ function getBidRequestData(reqBidsConfigObj, onDone, config, userConsent) { ext: { rx: rxBatch[bidderCode], events: singlePointEvents, + pos: btoa(JSON.stringify(adUnitsPositions)), sm: sm(), params: { ev: config.params?.version, diff --git a/test/spec/modules/contxtfulRtdProvider_spec.js b/test/spec/modules/contxtfulRtdProvider_spec.js index 68e38d63364..381104ad770 100644 --- a/test/spec/modules/contxtfulRtdProvider_spec.js +++ b/test/spec/modules/contxtfulRtdProvider_spec.js @@ -5,6 +5,7 @@ import { getStorageManager } from '../../../src/storageManager.js'; import { MODULE_TYPE_UID } from '../../../src/activities/modules.js'; import * as events from '../../../src/events'; import * as utils from 'src/utils.js'; +import * as gptUtils from '../../../libraries/gptUtils/gptUtils.js' import Sinon from 'sinon'; import { deepClone } from '../../../src/utils.js'; @@ -25,7 +26,7 @@ const RX_CONNECTOR_MOCK = { }; const TIMEOUT = 10; -const RX_CONNECTOR_IS_READY_EVENT = new CustomEvent('rxConnectorIsReady', { detail: {[CUSTOMER]: RX_CONNECTOR_MOCK}, bubbles: true }); +const RX_CONNECTOR_IS_READY_EVENT = new CustomEvent('rxConnectorIsReady', { detail: { [CUSTOMER]: RX_CONNECTOR_MOCK }, bubbles: true }); function buildInitConfig(version, customer) { return { @@ -40,6 +41,24 @@ function buildInitConfig(version, customer) { }; } +function fakeGetElementById(width, height, x, y) { + const obj = { x, y, width, height }; + + return { + ...obj, + getBoundingClientRect: () => { + return { + width: obj.width, + height: obj.height, + left: obj.x, + top: obj.y, + right: obj.x + obj.width, + bottom: obj.y + obj.height + }; + } + }; +} + describe('contxtfulRtdProvider', function () { let sandbox = sinon.sandbox.create(); let loadExternalScriptTag; @@ -332,7 +351,7 @@ describe('contxtfulRtdProvider', function () { theories.forEach(([adUnits, expected, _description]) => { it('uses non-expired info from session storage and adds receptivity to the ad units using session storage', function (done) { // Simulate that there was a write to sessionStorage in the past. - storage.setDataInSessionStorage(CUSTOMER, JSON.stringify({exp: new Date().getTime() + 1000, rx: RX_FROM_SESSION_STORAGE})) + storage.setDataInSessionStorage(CUSTOMER, JSON.stringify({ exp: new Date().getTime() + 1000, rx: RX_FROM_SESSION_STORAGE })) let config = buildInitConfig(VERSION, CUSTOMER); contxtfulSubmodule.init(config); @@ -365,7 +384,7 @@ describe('contxtfulRtdProvider', function () { theories.forEach(([adUnits, expected, _description]) => { it('ignores expired info from session storage and does not forward the info to ad units', function (done) { // Simulate that there was a write to sessionStorage in the past. - storage.setDataInSessionStorage(CUSTOMER, JSON.stringify({exp: new Date().getTime() - 100, rx: RX_FROM_SESSION_STORAGE})); + storage.setDataInSessionStorage(CUSTOMER, JSON.stringify({ exp: new Date().getTime() - 100, rx: RX_FROM_SESSION_STORAGE })); let config = buildInitConfig(VERSION, CUSTOMER); contxtfulSubmodule.init(config); @@ -466,7 +485,7 @@ describe('contxtfulRtdProvider', function () { // Simulate that there was a write to sessionStorage in the past. let bidder = config.params.bidders[0]; - storage.setDataInSessionStorage(`${config.params.customer}_${bidder}`, JSON.stringify({exp: new Date().getTime() + 1000, rx: RX_FROM_SESSION_STORAGE})); + storage.setDataInSessionStorage(`${config.params.customer}_${bidder}`, JSON.stringify({ exp: new Date().getTime() + 1000, rx: RX_FROM_SESSION_STORAGE })); let reqBidsConfigObj = { ortb2Fragments: { @@ -478,7 +497,7 @@ describe('contxtfulRtdProvider', function () { contxtfulSubmodule.init(config); // Since the RX_CONNECTOR_IS_READY_EVENT event was not dispatched, the RX engine is not loaded. - contxtfulSubmodule.getBidRequestData(reqBidsConfigObj, () => {}, config); + contxtfulSubmodule.getBidRequestData(reqBidsConfigObj, () => { }, config); setTimeout(() => { let ortb2BidderFragment = reqBidsConfigObj.ortb2Fragments.bidder[bidder]; @@ -659,7 +678,302 @@ describe('contxtfulRtdProvider', function () { done(); }, TIMEOUT); }); + }); + }); + + describe('when there is no ad units', function () { + it('adds empty ad unit positions', function (done) { + let config = buildInitConfig(VERSION, CUSTOMER); + contxtfulSubmodule.init(config); + let reqBidsConfigObj = { + ortb2Fragments: { + global: {}, + bidder: {}, + }, + }; + setTimeout(() => { + const onDoneSpy = sinon.spy(); + contxtfulSubmodule.getBidRequestData(reqBidsConfigObj, onDoneSpy, config); + + let ext = reqBidsConfigObj.ortb2Fragments.bidder[config.params.bidders[0]].user.data[0].ext; + let pos = JSON.parse(atob(ext.pos)); + + expect(Object.keys(pos).length).to.be.equal(0); + done(); + }, TIMEOUT); + }); + }); + + describe('when there are ad units', function () { + it('return empty objects for ad units that we can\'t get position of', function (done) { + let config = buildInitConfig(VERSION, CUSTOMER); + contxtfulSubmodule.init(config); + let reqBidsConfigObj = { + adUnits: [ + { code: 'code1' }, + { code: 'code2' } + ], + ortb2Fragments: { + global: {}, + bidder: {}, + }, + }; + setTimeout(() => { + const onDoneSpy = sinon.spy(); + contxtfulSubmodule.getBidRequestData(reqBidsConfigObj, onDoneSpy, config); + + let ext = reqBidsConfigObj.ortb2Fragments.bidder[config.params.bidders[0]].user.data[0].ext; + let pos = JSON.parse(atob(ext.pos)); + + expect(Object.keys(pos).length).to.be.equal(0); + done(); + }, TIMEOUT); + }); + + it('returns the IAB position if the ad unit div id cannot be bound but property pos can be found in the ad unit', function (done) { + let config = buildInitConfig(VERSION, CUSTOMER); + contxtfulSubmodule.init(config); + let reqBidsConfigObj = { + adUnits: [ + { code: 'code1', mediaTypes: { banner: { pos: 4 } } }, + { code: 'code2', mediaTypes: { banner: { pos: 5 } } }, + { code: 'code3', mediaTypes: { banner: { pos: 0 } } }, + ], + ortb2Fragments: { + global: {}, + bidder: {}, + }, + }; + setTimeout(() => { + const onDoneSpy = sinon.spy(); + contxtfulSubmodule.getBidRequestData(reqBidsConfigObj, onDoneSpy, config); + + let ext = reqBidsConfigObj.ortb2Fragments.bidder[config.params.bidders[0]].user.data[0].ext; + let pos = JSON.parse(atob(ext.pos)); + + expect(Object.keys(pos).length).to.be.equal(3); + expect(pos['code1'].p).to.be.equal(4); + expect(pos['code2'].p).to.be.equal(5); + expect(pos['code3'].p).to.be.equal(0); + done(); + }, TIMEOUT); }) + + function getFakeRequestBidConfigObj() { + return { + adUnits: [ + { code: 'code1', ortb2Imp: { ext: { data: { divId: 'divId1' } } } }, + { code: 'code2', ortb2Imp: { ext: { data: { divId: 'divId2' } } } } + ], + ortb2Fragments: { + global: {}, + bidder: {}, + }, + }; + } + + function InitDivStubPositions(config, withIframe, isVisible, forceGetElementById = true) { + let fakeElem = fakeGetElementById(100, 100, 30, 30); + if (isVisible) { + fakeElem.checkVisibility = function () { return true }; + sandbox.stub(window.top, 'getComputedStyle').returns({ display: 'block' }); + } else { + fakeElem.checkVisibility = function () { return false }; + sandbox.stub(window.top, 'getComputedStyle').returns({ display: 'none' }); + } + + if (withIframe) { + let ws = { + frameElement: { + getBoundingClientRect: () => fakeElem.getBoundingClientRect() + }, + document: { + getElementById: (id) => fakeElem, + + } + } + sandbox.stub(utils, 'getWindowSelf').returns(window.top); + sandbox.stub(utils, 'inIframe').returns(true); + sandbox.stub(fakeElem, 'checkVisibility').returns(isVisible); + } else { + sandbox.stub(utils, 'inIframe').returns(false); + sandbox.stub(fakeElem, 'checkVisibility').returns(isVisible); + } + if (forceGetElementById) { + sandbox.stub(window.top.document, 'getElementById').returns(fakeElem); + } + contxtfulSubmodule.init(config); + } + + describe('when the div id cannot be found, we should try with GPT method', function () { + it('returns an empty list if gpt not find the div', function (done) { + let config = buildInitConfig(VERSION, CUSTOMER); + let reqBidsConfigObj = { + adUnits: [ + { code: 'code1' }, + { code: 'code2' } + ], + ortb2Fragments: { + global: {}, + bidder: {}, + }, + }; + InitDivStubPositions(config, false, true, false); + let fakeElem = fakeGetElementById(100, 100, 30, 30); + sandbox.stub(window.top.document, 'getElementById').returns(function (id) { + if (id == 'code1' || id == 'code2') { + return undefined; + } else { + return fakeElem; + } + }); + setTimeout(() => { + const onDoneSpy = sinon.spy(); + contxtfulSubmodule.getBidRequestData(reqBidsConfigObj, onDoneSpy, config); + + let ext = reqBidsConfigObj.ortb2Fragments.bidder[config.params.bidders[0]].user.data[0].ext; + let pos = JSON.parse(atob(ext.pos)); + + expect(Object.keys(pos).length).to.be.equal(0); + done(); + }, TIMEOUT); + }) + + it('returns object visibility and position if gpt not found but the div id is the ad unit code', function (done) { + let config = buildInitConfig(VERSION, CUSTOMER); + let reqBidsConfigObj = { + adUnits: [ + { code: 'code1' }, + { code: 'code2' } + ], + ortb2Fragments: { + global: {}, + bidder: {}, + }, + }; + InitDivStubPositions(config, false, true); + setTimeout(() => { + const onDoneSpy = sinon.spy(); + contxtfulSubmodule.getBidRequestData(reqBidsConfigObj, onDoneSpy, config); + + let ext = reqBidsConfigObj.ortb2Fragments.bidder[config.params.bidders[0]].user.data[0].ext; + let pos = JSON.parse(atob(ext.pos)); + + expect(Object.keys(pos).length).to.be.equal(2); + expect(pos['code1'].p.x).to.be.equal(30); + expect(pos['code1'].p.y).to.be.equal(30); + expect(pos['code1'].v).to.be.equal(true); + done(); + }, TIMEOUT); + }); + + it('returns object visibility and position if gpt finds the div', function (done) { + let config = buildInitConfig(VERSION, CUSTOMER); + let reqBidsConfigObj = { + adUnits: [ + { code: 'code1' }, + { code: 'code2' } + ], + ortb2Fragments: { + global: {}, + bidder: {}, + }, + }; + InitDivStubPositions(config, false, true); + sandbox.stub(gptUtils, 'getGptSlotInfoForAdUnitCode').returns({ divId: 'div1' }); + + setTimeout(() => { + const onDoneSpy = sinon.spy(); + contxtfulSubmodule.getBidRequestData(reqBidsConfigObj, onDoneSpy, config); + + let ext = reqBidsConfigObj.ortb2Fragments.bidder[config.params.bidders[0]].user.data[0].ext; + let pos = JSON.parse(atob(ext.pos)); + + expect(Object.keys(pos).length).to.be.equal(2); + expect(pos['code1'].p.x).to.be.equal(30); + expect(pos['code1'].p.y).to.be.equal(30); + expect(pos['code1'].v).to.be.equal(true); + done(); + }, TIMEOUT); + }); + }); + + describe('when we get object visibility and position for ad units that we can get div id', function () { + let config = buildInitConfig(VERSION, CUSTOMER); + + describe('when we are not in an iframe', function () { + it('return object visibility true if element is visible', function (done) { + let reqBidsConfigObj = getFakeRequestBidConfigObj(); + InitDivStubPositions(config, false, true); + setTimeout(() => { + const onDoneSpy = sinon.spy(); + contxtfulSubmodule.getBidRequestData(reqBidsConfigObj, onDoneSpy, config); + + let ext = reqBidsConfigObj.ortb2Fragments.bidder[config.params.bidders[0]].user.data[0].ext; + let pos = JSON.parse(atob(ext.pos)); + + expect(Object.keys(pos).length).to.be.equal(2); + expect(pos['code1'].p.x).to.be.equal(30); + expect(pos['code1'].p.y).to.be.equal(30); + expect(pos['code1'].v).to.be.equal(true); + done(); + }, TIMEOUT); + }); + + it('return object visibility false if element is not visible', function (done) { + let reqBidsConfigObj = getFakeRequestBidConfigObj(); + InitDivStubPositions(config, false, false); + setTimeout(() => { + const onDoneSpy = sinon.spy(); + contxtfulSubmodule.getBidRequestData(reqBidsConfigObj, onDoneSpy, config); + + let ext = reqBidsConfigObj.ortb2Fragments.bidder[config.params.bidders[0]].user.data[0].ext; + let pos = JSON.parse(atob(ext.pos)); + + expect(Object.keys(pos).length).to.be.equal(2); + expect(pos['code1'].v).to.be.equal(false); + expect(pos['code2'].v).to.be.equal(false); + done(); + }, TIMEOUT); + }); + }); + + describe('when we are in an iframe', function () { + it('return object visibility true if element is visible', function (done) { + let reqBidsConfigObj = getFakeRequestBidConfigObj(); + InitDivStubPositions(config, true, true) + setTimeout(() => { + const onDoneSpy = sinon.spy(); + contxtfulSubmodule.getBidRequestData(reqBidsConfigObj, onDoneSpy, config); + + let ext = reqBidsConfigObj.ortb2Fragments.bidder[config.params.bidders[0]].user.data[0].ext; + let pos = JSON.parse(atob(ext.pos)); + + expect(Object.keys(pos).length).to.be.equal(2); + expect(pos['code1'].p.x).to.be.equal(30); + expect(pos['code1'].p.y).to.be.equal(30); + expect(pos['code1'].v).to.be.equal(true); + done(); + }, TIMEOUT); + }); + + it('return object visibility false if element is not visible', function (done) { + let reqBidsConfigObj = getFakeRequestBidConfigObj(); + InitDivStubPositions(config, true, false); + setTimeout(() => { + const onDoneSpy = sinon.spy(); + contxtfulSubmodule.getBidRequestData(reqBidsConfigObj, onDoneSpy, config); + + let ext = reqBidsConfigObj.ortb2Fragments.bidder[config.params.bidders[0]].user.data[0].ext; + let pos = JSON.parse(atob(ext.pos)); + + expect(Object.keys(pos).length).to.be.equal(2); + expect(pos['code1'].v).to.be.equal(false); + done(); + }, TIMEOUT); + }); + }); + }); }); describe('after rxApi is loaded', function () { From 8615c3ce7b9e429a54466801405a1a671acfd1cf Mon Sep 17 00:00:00 2001 From: rufiange Date: Wed, 19 Feb 2025 16:05:36 -0500 Subject: [PATCH 2/4] fix: unused variable --- modules/contxtfulRtdProvider.js | 1 - 1 file changed, 1 deletion(-) diff --git a/modules/contxtfulRtdProvider.js b/modules/contxtfulRtdProvider.js index 2c7f478d8c9..3029864049a 100644 --- a/modules/contxtfulRtdProvider.js +++ b/modules/contxtfulRtdProvider.js @@ -353,7 +353,6 @@ function tryMultipleDivIdPositions(adUnit) { function tryGetAdUnitPosition(adUnit) { let adUnitPosition = {}; adUnit.ortb2Imp = adUnit.ortb2Imp || {}; - const ortb2Imp = deepAccess(adUnit, 'ortb2Imp'); // try to get position with the divId const divIdPosition = tryMultipleDivIdPositions(adUnit); From 5539c97a2a5e6383d53d3f2874277f9df22af01f Mon Sep 17 00:00:00 2001 From: rufiange Date: Wed, 19 Feb 2025 17:00:25 -0500 Subject: [PATCH 3/4] doc: update --- modules/contxtfulRtdProvider.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/modules/contxtfulRtdProvider.js b/modules/contxtfulRtdProvider.js index 3029864049a..0081b812bd5 100644 --- a/modules/contxtfulRtdProvider.js +++ b/modules/contxtfulRtdProvider.js @@ -354,7 +354,7 @@ function tryGetAdUnitPosition(adUnit) { let adUnitPosition = {}; adUnit.ortb2Imp = adUnit.ortb2Imp || {}; - // try to get position with the divId + // Try to get position with the divId const divIdPosition = tryMultipleDivIdPositions(adUnit); if (divIdPosition) { adUnitPosition.p = { x: divIdPosition.x, y: divIdPosition.y }; @@ -363,7 +363,7 @@ function tryGetAdUnitPosition(adUnit) { return adUnitPosition; } - // try to get IAB position + // Try to get IAB position const iabPos = adUnit?.mediaTypes?.banner?.pos; if (iabPos !== undefined) { adUnitPosition.p = iabPos; From 87a302ea0405fae816204e0b47989ed1d7407aa9 Mon Sep 17 00:00:00 2001 From: rufiange Date: Wed, 19 Feb 2025 17:42:27 -0500 Subject: [PATCH 4/4] doc: space --- modules/contxtfulRtdProvider.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/modules/contxtfulRtdProvider.md b/modules/contxtfulRtdProvider.md index de2376e782d..8fef61b3d96 100644 --- a/modules/contxtfulRtdProvider.md +++ b/modules/contxtfulRtdProvider.md @@ -8,7 +8,7 @@ The Contxtful RTD module offers a unique feature—Receptivity. Receptivity is an efficiency metric, enabling the qualification of any instant in a session in real time based on attention. The core idea is straightforward: the likelihood of an ad’s success increases when it grabs attention and is presented in the right context at the right time. -To utilize this module, you need to register for an account with [Contxtful](https://contxtful.com). For inquiries, please reach out to [contact@contxtful.com](mailto:contact@contxtful.com). +To utilize this module, you need to register for an account with [Contxtful](https://contxtful.com). For inquiries, please reach out to [contact@contxtful.com](mailto:contact@contxtful.com). ## Build Instructions