diff --git a/integrationExamples/realTimeData/datamageRtdProvider_example.html b/integrationExamples/realTimeData/datamageRtdProvider_example.html new file mode 100644 index 00000000000..6402f37a397 --- /dev/null +++ b/integrationExamples/realTimeData/datamageRtdProvider_example.html @@ -0,0 +1,142 @@ + + + + + + OpsMage Prebid Test Page + + + + + + + + + + +

OpsMage Prebid Test Page

+
+
The tech world is currently buzzing over the highly anticipated market debut of fakeDSP, a trailblazing + startup poised to redefine the landscape of digital signal processing. Leveraging proprietary neuromorphic + algorithms and quantum-ready architecture, fakeDSP promises to accelerate real-time data synthesis by speeds + previously thought impossible. With early analysts calling it a definitive disruptor in both the + telecommunications and audio-engineering sectors, the company’s entrance signifies a major leap forward in how + complex signals are analyzed and reconstructed in the AI era.
+ + + \ No newline at end of file diff --git a/modules/datamageRrdProvider.md b/modules/datamageRrdProvider.md new file mode 100644 index 00000000000..4cb705824db --- /dev/null +++ b/modules/datamageRrdProvider.md @@ -0,0 +1,86 @@ +# DataMage RTD Submodule + +DataMage provides real-time contextual classification (IAB Categories, Sentiment, Brands, Locations, Public Figures, Restricted Categories, and related IDs) that can be used to enrich demand signals and Google Ad Manager targeting. + +## What it does + +DataMage automatically supports two outcomes in a Prebid + GAM setup without requiring any custom glue-code on the page: + +1) **Passes data to Google Ad Manager (Direct GPT targeting)** +- The moment Prebid initializes, DataMage fetches classification for the current page and automatically pushes the targeting keys directly to GPT via `googletag.pubads().setTargeting(...)` at the page level. +- This ensures the data is available for all ad slots and works **even if there are no bids** or if the auction times out. + +2) **Passes data to bidders (ORTB2 enrichment)** +- Using a memoized cache from the initial fetch, DataMage seamlessly inserts the contextual results into the bid request using OpenRTB (`ortb2Fragments.global.site.content.data`), allowing bidders to receive the enriched signals instantly. + +## Keys provided + +DataMage automatically maps and provides the following targeting keys (when available in the API response): + +- `om_iab_cat_ids` +- `om_iab_cats` +- `om_brand_ids` +- `om_sentiment_ids` +- `om_location_ids` +- `om_public_figure_ids` +- `om_restricted_cat_ids` +- `om_restricted_cats` +- `om_ops_mage_data_id` +- `om_res_score_bucket` +- `om_res_score` (only when present) + + + +##Prebid config +``` +pbjs.setConfig({ + realTimeData: { + auctionDelay: 1000, // Gives the module time to fetch data before bids are sent, suggested minimum 1000 + dataProviders: [{ + name: "datamage", + params: { + api_key: "YOUR_API_KEY", + selector: "article", + fetch_timeout_ms: 2500 + } + }] + } +}); +``` + +##GAM set up requirements + +Because DataMage now automatically injects targeting globally into pubads(), your page implementation only requires a standard Prebid setup. + +To ensure DataMage key-values are included in your GAM requests: + +Call googletag.pubads().disableInitialLoad() before your ad requests. + +Define your slots and call googletag.enableServices(). + +Run pbjs.requestBids(...). + +Inside the bidsBackHandler callback: + +Call pbjs.setTargetingForGPTAsync() (to set standard Prebid hb_ pricing keys). + +Call googletag.pubads().refresh() to trigger the GAM request. + +GAM will automatically combine the standard Prebid slot-level pricing keys with the page-level DataMage contextual keys. + +Note that you will need a _real_ api key provisioned by data mage to use this module in production. + +Example: +```pbjs.requestBids({ + bidsBackHandler: function () { + // Push standard header bidding keys to GPT + pbjs.setTargetingForGPTAsync(); + + // Refresh the ad slots. Datamage keys are already injected! + googletag.cmd.push(function () { + googletag.pubads().refresh(); + }); + }, + timeout: 1500 +}); +``` \ No newline at end of file diff --git a/modules/datamageRtdProvider.js b/modules/datamageRtdProvider.js new file mode 100644 index 00000000000..59b8b4e96f3 --- /dev/null +++ b/modules/datamageRtdProvider.js @@ -0,0 +1,211 @@ +import { submodule } from '../src/hook.js'; +import { logError, logWarn, logInfo, generateUUID } from '../src/utils.js'; +import { ajaxBuilder } from '../src/ajax.js'; + +const MODULE_NAME = 'datamage'; + +let fetchPromise = null; +let lastTargeting = null; + +function _resetForTest() { + fetchPromise = null; // Clear the network promise cache + lastTargeting = null; // Clear the data targeting cache +} + +function asStringArray(v) { + if (v == null) return []; + if (Array.isArray(v)) return v.map((x) => String(x)); + return [String(v)]; +} + +function ensureSiteContentData(globalOrtb2) { + if (!globalOrtb2.site) globalOrtb2.site = {}; + if (!globalOrtb2.site.content) globalOrtb2.site.content = {}; + if (!Array.isArray(globalOrtb2.site.content.data)) globalOrtb2.site.content.data = []; + return globalOrtb2.site.content.data; +} + +function buildSegments(iabCatIds, iabCats) { + const ids = asStringArray(iabCatIds); + const names = Array.isArray(iabCats) ? iabCats.map((x) => String(x)) : []; + return ids.map((id, idx) => { + const seg = { id }; + if (names[idx]) seg.name = names[idx]; + return seg; + }); +} + +function padBase64(b64) { + const mod = b64.length % 4; + return mod ? (b64 + '='.repeat(4 - mod)) : b64; +} + +function stripPortFromUrl(urlStr) { + try { + const u = new URL(urlStr); + if (u.port) u.port = ''; + return u.toString(); + } catch (e) { + return urlStr; + } +} + +function buildApiUrl(params) { + const apiKey = params.api_key || ''; + const selector = params.selector || ''; + const rawPageUrl = (typeof window !== 'undefined' && window.location?.href) ? window.location.href : ''; + const pageUrl = stripPortFromUrl(rawPageUrl); + + let encodedUrl = ''; + try { encodedUrl = padBase64(btoa(pageUrl)); } catch (e) { } + + return `https://opsmage-api.io/context/v3/get?api_key=${encodeURIComponent(apiKey)}&content_id=${encodedUrl}&prebid=true&selector=${encodeURIComponent(selector)}`; +} + +function fetchContextData(apiUrl, fetchTimeoutMs) { + if (fetchPromise) return fetchPromise; + + const ajax = ajaxBuilder(fetchTimeoutMs); + fetchPromise = new Promise((resolve, reject) => { + ajax(apiUrl, { + success: (responseText) => { + try { resolve(JSON.parse(responseText)); } catch (err) { reject(err); } + }, + error: (err) => reject(err) + }); + }); + + return fetchPromise; +} + +/** + * Helper to parse the API payload so we don't repeat mapping logic + */ +function mapApiPayload(cc) { + const arrayKeys = ['brand_ids', 'sentiment_ids', 'location_ids', 'public_figure_ids', 'restricted_cat_ids', 'restricted_cats']; + const scalarKeys = ['ops_mage_data_id', 'res_score', 'res_score_bucket']; + + const ext = {}; + const targetingArrays = {}; + lastTargeting = {}; + + const iabCats = asStringArray(cc.iab_cats); + const iabCatIds = asStringArray(cc.iab_cat_ids); + targetingArrays.om_iab_cat_ids = iabCatIds; + targetingArrays.om_iab_cats = iabCats; + lastTargeting.om_iab_cat_ids = iabCatIds.join(','); + lastTargeting.om_iab_cats = iabCats.join(','); + + arrayKeys.forEach((key) => { + const vals = asStringArray(cc[key]); + ext[key] = vals; + targetingArrays[`om_${key}`] = vals; + lastTargeting[`om_${key}`] = vals.join(','); + }); + + scalarKeys.forEach((key) => { + if (cc[key] != null) { + ext[key] = cc[key]; + targetingArrays[`om_${key}`] = [String(cc[key])]; + lastTargeting[`om_${key}`] = String(cc[key]); + } + }); + + return { ext, targetingArrays, segment: buildSegments(iabCatIds, iabCats) }; +} + +// ========================================== +// 1. PUBLISHER TARGETING (Independent of Auction) +// ========================================== +function init(rtdConfig, userConsent) { + logInfo('DATAMAGE: init() called. Fetching data for GAM...'); + + const params = (rtdConfig && rtdConfig.params) || {}; + if (!params.api_key) logWarn('DataMage: Missing api_key'); + + const apiUrl = buildApiUrl(params); + const fetchTimeoutMs = Number(params.fetch_timeout_ms ?? 2500); + + // Start network request instantly and push to GPT regardless of bids + fetchContextData(apiUrl, fetchTimeoutMs).then((resJson) => { + if (!resJson?.content_classification) { + lastTargeting = null; // FIX: Clear stale cache on empty payload + return; + } + + const { targetingArrays } = mapApiPayload(resJson.content_classification); + + window.googletag = window.googletag || { cmd: [] }; + window.googletag.cmd.push(() => { + Object.entries(targetingArrays).forEach(([key, value]) => { + if (value.length) window.googletag.pubads().setTargeting(key, value); + }); + }); + }).catch(() => { + lastTargeting = null; // FIX: Clear stale cache on error + }); + + return true; +} + +// ========================================== +// 2. ADVERTISER TARGETING (Tied to Auction) +// ========================================== +function getBidRequestData(reqBidsConfigObj, callback, rtdConfig, userConsent) { + logInfo('DATAMAGE: getBidRequestData() triggered. Attaching to ORTB2...'); + + if (!reqBidsConfigObj?.ortb2Fragments?.global) { + callback(); + return; + } + + const params = (rtdConfig && rtdConfig.params) || {}; + const apiUrl = buildApiUrl(params); + const fetchTimeoutMs = Number(params.fetch_timeout_ms ?? 2500); + + reqBidsConfigObj.auctionId = reqBidsConfigObj.auctionId || generateUUID(); + + // This will instantly resolve from the cache created in init() + fetchContextData(apiUrl, fetchTimeoutMs) + .then((resJson) => { + if (!resJson?.content_classification) { + lastTargeting = null; // FIX: Clear stale cache on empty payload + return; + } + + const { ext, segment } = mapApiPayload(resJson.content_classification); + + const ortbContentDataObj = { name: 'data-mage.com', segment, ext }; + ensureSiteContentData(reqBidsConfigObj.ortb2Fragments.global).push(ortbContentDataObj); + }) + .catch((error) => { + lastTargeting = null; // FIX: Clear stale cache on error + logError('DataMage: Fetch error', error); + }) + .finally(() => callback()); // Release the auction! +} + +function getTargetingData(adUnitCodes, rtdConfig, userConsent) { + if (!lastTargeting) return {}; + + const out = {}; + + // Iterate over the array of string codes passed by Prebid + (adUnitCodes || []).forEach((code) => { + if (typeof code === 'string' && code) { + out[code] = { ...lastTargeting }; + } + }); + + return out; +} + +export const datamageRtdSubmodule = { + name: MODULE_NAME, + init, + getBidRequestData, + getTargetingData, + _resetForTest +}; + +submodule('realTimeData', datamageRtdSubmodule); diff --git a/test/spec/modules/datamageRtdProvider_spec.js b/test/spec/modules/datamageRtdProvider_spec.js new file mode 100644 index 00000000000..9662ed168ba --- /dev/null +++ b/test/spec/modules/datamageRtdProvider_spec.js @@ -0,0 +1,246 @@ +import { expect } from 'chai'; +import sinon from 'sinon'; +import { datamageRtdSubmodule } from 'modules/datamageRtdProvider.js'; +import * as ajaxUtils from 'src/ajax.js'; +import * as utils from 'src/utils.js'; + +describe('datamageRtdSubmodule (DataMage RTD Provider)', function () { + let sandbox; + let ajaxBuilderStub; + let setTargetingStub; + let btoaStub; + let origGoogletag; // Stores the original global to prevent breaking other tests + + function makeReqBidsConfigObj() { + return { + auctionId: 'auction-1', + ortb2Fragments: { global: {} } + }; + } + + function makeProcessedResponse(overrides = {}) { + return { + content_classification: { + ops_mage_data_id: '7d54b2d30a4e441a0f698dfae8f5b1b5', + res_score: 1, + res_score_bucket: 'high', + iab_cats: [ + 'Technology & Computing', + 'Technology & Computing|Artificial Intelligence', + 'Business & Finance' + ], + iab_cat_ids: ['596', '597', '52'], + brand_ids: ['eefd8446', 'b78b9ee2'], + sentiment_ids: ['95487831', '92bfd7eb'], + location_ids: ['60efc224'], + public_figure_ids: ['55eefb4a'], + restricted_cat_ids: [], + ...overrides + } + }; + } + + beforeEach(function () { + sandbox = sinon.createSandbox(); + + // Stub logging so they don't spam the test console + sandbox.stub(utils, 'logInfo'); + sandbox.stub(utils, 'logWarn'); + sandbox.stub(utils, 'logError'); + + // Reset module-scoped cache + datamageRtdSubmodule._resetForTest(); + + // Safely backup the original googletag object + origGoogletag = window.googletag; + + // Mock window.googletag and spy on setTargeting + setTargetingStub = sandbox.stub(); + window.googletag = { + cmd: { + push: function (fn) { fn(); } // Execute immediately for testing + }, + pubads: function () { + return { setTargeting: setTargetingStub }; + } + }; + + // Stub Prebid's internal ajaxBuilder + ajaxBuilderStub = sandbox.stub(ajaxUtils, 'ajaxBuilder'); + + // Keep tests deterministic + allow port-strip assertion + btoaStub = sandbox.stub(window, 'btoa').callsFake((s) => `b64(${s})`); + }); + + afterEach(function () { + sandbox.restore(); + // Restore the original googletag object so we don't break the E-Planning adapter + window.googletag = origGoogletag; + }); + + describe('init()', function () { + it('should return true and trigger GAM injection asynchronously', function (done) { + let fakeAjax = sinon.stub(); + ajaxBuilderStub.returns(fakeAjax); + + const ok = datamageRtdSubmodule.init({ name: 'datamage', params: { api_key: 'x' } }, {}); + expect(ok).to.equal(true); + + // Simulate the API resolving + const callbacks = fakeAjax.firstCall.args[1]; + callbacks.success(JSON.stringify(makeProcessedResponse())); + + // Use setTimeout to wait for the Promise chain to resolve + setTimeout(() => { + expect(setTargetingStub.calledWith('om_iab_cat_ids', ['596', '597', '52'])).to.be.true; + expect(setTargetingStub.calledWith('om_brand_ids', ['eefd8446', 'b78b9ee2'])).to.be.true; + expect(setTargetingStub.calledWith('om_res_score', ['1'])).to.be.true; + expect(setTargetingStub.calledWith('om_restricted_cat_ids')).to.be.false; + done(); + }, 0); + }); + }); + + describe('getBidRequestData()', function () { + it('should inject into ORTB2 when fetch resolves', function (done) { + const req = makeReqBidsConfigObj(); + let fakeAjax = sinon.stub(); + ajaxBuilderStub.returns(fakeAjax); + + const rtdConfig = { + name: 'datamage', + params: { api_key: 'k', selector: 'article' } + }; + + datamageRtdSubmodule.getBidRequestData(req, () => { + expect(req.ortb2Fragments.global).to.have.nested.property('site.content.data'); + const dataArr = req.ortb2Fragments.global.site.content.data; + expect(dataArr).to.be.an('array').with.length.greaterThan(0); + expect(dataArr[0]).to.have.property('name', 'data-mage.com'); + expect(dataArr[0]).to.have.property('segment'); + expect(dataArr[0].segment).to.deep.include({ id: '596', name: 'Technology & Computing' }); + done(); + }, rtdConfig, {}); + + const callbacks = fakeAjax.firstCall.args[1]; + callbacks.success(JSON.stringify(makeProcessedResponse())); + }); + + it('should only make ONE network request when init and getBidRequestData are both called (Memoization)', function (done) { + const req = makeReqBidsConfigObj(); + let fakeAjax = sinon.stub(); + ajaxBuilderStub.returns(fakeAjax); + + const rtdConfig = { params: { api_key: 'k' } }; + + // 1. Init fires (simulating page load) + datamageRtdSubmodule.init(rtdConfig); + + // 2. getBidRequestData fires (simulating auction start) + datamageRtdSubmodule.getBidRequestData(req, () => { + // Assert the network was only hit once despite two entry points + expect(fakeAjax.calledOnce).to.be.true; + done(); + }, rtdConfig, {}); + + const callbacks = fakeAjax.firstCall.args[1]; + callbacks.success(JSON.stringify(makeProcessedResponse())); + }); + + it('should NOT inject after network error', function (done) { + const req = makeReqBidsConfigObj(); + let fakeAjax = sinon.stub(); + ajaxBuilderStub.returns(fakeAjax); + + datamageRtdSubmodule.getBidRequestData(req, () => { + expect(req.ortb2Fragments.global.site?.content?.data).to.be.undefined; + expect(setTargetingStub.called).to.be.false; + done(); + }, { name: 'datamage', params: { api_key: 'k' } }, {}); + + const callbacks = fakeAjax.firstCall.args[1]; + callbacks.error('Network Failed'); + }); + + it('should strip port from URL before encoding', function (done) { + const req = makeReqBidsConfigObj(); + let fakeAjax = sinon.stub(); + ajaxBuilderStub.returns(fakeAjax); + + datamageRtdSubmodule.getBidRequestData(req, () => { + expect(btoaStub.called).to.equal(true); + const btoaArg = btoaStub.firstCall.args[0]; + + expect(btoaArg).to.be.a('string'); + expect(btoaArg).to.not.match(/\/\/[^/]+:\d+\//); + done(); + }, { name: 'datamage', params: { api_key: 'k' } }, {}); + + const callbacks = fakeAjax.firstCall.args[1]; + callbacks.error('err'); + }); + + it('should clear stale cache (lastTargeting) if the fetch yields no payload', function (done) { + const req1 = makeReqBidsConfigObj(); + const req2 = makeReqBidsConfigObj(); + let fakeAjax = sinon.stub(); + ajaxBuilderStub.returns(fakeAjax); + + const rtdConfig = { name: 'datamage', params: { api_key: 'k' } }; + + // 1. First auction: successful fetch populates the cache + datamageRtdSubmodule.getBidRequestData(req1, () => { + // 2. Verify cache is populated + let out = datamageRtdSubmodule.getTargetingData(['div-1'], {}, {}); + expect(out['div-1']).to.have.property('om_res_score', '1'); + + // 3. Reset module state safely via the internal helper + datamageRtdSubmodule._resetForTest(); + + // 4. Second auction: simulate an empty response + datamageRtdSubmodule.getBidRequestData(req2, () => { + // 5. Verify the cache was wiped out + out = datamageRtdSubmodule.getTargetingData(['div-1'], {}, {}); + expect(out).to.deep.equal({}); + done(); + }, rtdConfig, {}); + + // Resolve the second auction with an empty payload + const callbacks2 = fakeAjax.secondCall.args[1]; + callbacks2.success(JSON.stringify({})); + }, rtdConfig, {}); + + // Resolve the first auction with a good payload + const callbacks1 = fakeAjax.firstCall.args[1]; + callbacks1.success(JSON.stringify(makeProcessedResponse())); + }); + }); + + describe('getTargetingData()', function () { + it('should return {} if no successful fetch has happened yet', function () { + const out = datamageRtdSubmodule.getTargetingData(['div-1'], {}, {}); + expect(out).to.deep.equal({}); + }); + + it('should return per-adunit legacy targeting (string-joined lists) after response resolves', function (done) { + const req = makeReqBidsConfigObj(); + let fakeAjax = sinon.stub(); + ajaxBuilderStub.returns(fakeAjax); + + datamageRtdSubmodule.getBidRequestData(req, () => { + const out = datamageRtdSubmodule.getTargetingData(['div-1', 'div-2'], {}, {}); + expect(out).to.have.property('div-1'); + expect(out).to.have.property('div-2'); + + expect(out['div-1']).to.have.property('om_iab_cat_ids', '596,597,52'); + expect(out['div-1']).to.have.property('om_brand_ids', 'eefd8446,b78b9ee2'); + expect(out['div-1']).to.have.property('om_res_score', '1'); + + done(); + }, { name: 'datamage', params: { api_key: 'k' } }, {}); + + const callbacks = fakeAjax.firstCall.args[1]; + callbacks.success(JSON.stringify(makeProcessedResponse())); + }); + }); +});