diff --git a/modules/.submodules.json b/modules/.submodules.json index 6dac11bf0ed..cccee69afdd 100644 --- a/modules/.submodules.json +++ b/modules/.submodules.json @@ -101,6 +101,7 @@ "qortexRtdProvider", "reconciliationRtdProvider", "relevadRtdProvider", + "semantiqRtdProvider", "sirdataRtdProvider", "symitriDapRtdProvider", "timeoutRtdProvider", diff --git a/modules/semantiqRtdProvider.js b/modules/semantiqRtdProvider.js new file mode 100644 index 00000000000..563f2d12449 --- /dev/null +++ b/modules/semantiqRtdProvider.js @@ -0,0 +1,214 @@ +import { MODULE_TYPE_RTD } from '../src/activities/modules.js'; +import { ajax } from '../src/ajax.js'; +import { submodule } from '../src/hook.js'; +import { getStorageManager } from '../src/storageManager.js'; +import { getWindowLocation, logError, logInfo, logWarn, mergeDeep } from '../src/utils.js'; + +/** + * @typedef {import('../modules/rtdModule/index.js').RtdSubmodule} RtdSubmodule + */ + +const MODULE_NAME = 'realTimeData'; +const SUBMODULE_NAME = 'semantiq'; + +const LOG_PREFIX = '[SemantIQ RTD Module]: '; +const KEYWORDS_URL = 'https://api.adnz.co/api/ws-semantiq/page-keywords'; +const STORAGE_KEY = `adnz_${SUBMODULE_NAME}`; +const AUDIENZZ_COMPANY_ID = 1; + +const DEFAULT_TIMEOUT = 1000; + +export const storage = getStorageManager({ + moduleType: MODULE_TYPE_RTD, + moduleName: SUBMODULE_NAME, +}); + +/** + * Gets SemantIQ keywords from local storage. + * @param {string} pageUrl + * @returns {Object.} + */ +const getStorageKeywords = (pageUrl) => { + try { + const storageValue = JSON.parse(storage.getDataFromLocalStorage(STORAGE_KEY)); + + if (storageValue?.url === pageUrl) { + return storageValue.keywords; + } + + return null; + } catch (error) { + logError('Unable to get SemantiQ keywords from local storage', error); + + return null; + } +}; + +/** + * Gets URL of the current page. + * @returns {string} + */ +const getPageUrl = () => getWindowLocation().href; + +/** + * Gets tenant IDs based on the customer company ID + * @param {number} customerCompanyId + * @returns {number[]} + */ +const getTenantIds = (customerCompanyId) => { + const requiredTenantIds = [AUDIENZZ_COMPANY_ID]; + + if (customerCompanyId) { + return [...requiredTenantIds, customerCompanyId]; + } + + return requiredTenantIds; +}; + +/** + * Gets keywords from cache or SemantIQ service. + * @param {Object} params + * @returns {Promise>} + */ +const getKeywords = (params) => new Promise((resolve, reject) => { + const pageUrl = getPageUrl(); + const storageKeywords = getStorageKeywords(pageUrl); + + if (storageKeywords) { + return resolve(storageKeywords); + } + + const { companyId } = params; + const tenantIds = getTenantIds(companyId); + const searchParams = new URLSearchParams(); + + searchParams.append('url', pageUrl); + searchParams.append('tenantIds', tenantIds.join(',')); + + const requestUrl = `${KEYWORDS_URL}?${searchParams.toString()}`; + + const callbacks = { + success(responseText, response) { + try { + if (response.status !== 200) { + throw new Error('Invalid response status'); + } + + const data = JSON.parse(responseText); + + if (!data) { + throw new Error('Failed to parse the response'); + } + + storage.setDataInLocalStorage(STORAGE_KEY, JSON.stringify({ url: pageUrl, keywords: data })); + resolve(data); + } catch (error) { + reject(error); + } + }, + error(error) { + reject(error); + } + } + + ajax(requestUrl, callbacks); +}); + +/** + * Converts a single key-value pair to an ORTB keyword string. + * @param {string} key + * @param {string | string[]} value + * @returns {string} + */ +export const convertSemantiqKeywordToOrtb = (key, value) => { + if (Array.isArray(value) && value.length) { + return value.map((valueItem) => `${key}=${valueItem}`).join(','); + } + + return `${key}=${value.length ? value : 'none'}`; +}; + +/** + * Converts SemantIQ keywords to ORTB format. + * @param {Object.} keywords + * @returns {string} + */ +export const getOrtbKeywords = (keywords) => Object.entries(keywords).map((entry) => { + const [key, values] = entry; + + return convertSemantiqKeywordToOrtb(key, values); +}).join(','); + +/** + * Module init + * @param {Object} config + * @param {Object} userConsent + * @return {boolean} + */ +const init = (config, userConsent) => true; + +/** + * Receives real-time data from SemantIQ service. + * @param {Object} reqBidsConfigObj + * @param {function} onDone + * @param {Object} moduleConfig + */ +const getBidRequestData = ( + reqBidsConfigObj, + onDone, + moduleConfig, +) => { + let isDone = false; + + const { params = {} } = moduleConfig || {}; + const { timeout = DEFAULT_TIMEOUT } = params; + + try { + logInfo(LOG_PREFIX, { reqBidsConfigObj }); + + const { adUnits = [] } = reqBidsConfigObj; + + if (!adUnits.length) { + logWarn(LOG_PREFIX, 'No ad units found in the request'); + isDone = true; + onDone(); + } + + getKeywords(params) + .then((keywords) => { + const ortbKeywords = getOrtbKeywords(keywords); + const siteKeywords = reqBidsConfigObj.ortb2Fragments?.global?.site?.keywords; + const updatedGlobalOrtb = { site: { keywords: [siteKeywords, ortbKeywords].filter(Boolean).join(',') } }; + + mergeDeep(reqBidsConfigObj.ortb2Fragments.global, updatedGlobalOrtb); + }) + .catch((error) => { + logError(LOG_PREFIX, error); + }) + .finally(() => { + isDone = true; + onDone(); + }); + } catch (error) { + logError(LOG_PREFIX, error); + isDone = true; + onDone(); + } + + setTimeout(() => { + if (!isDone) { + logWarn(LOG_PREFIX, 'Timeout exceeded'); + isDone = true; + onDone(); + } + }, timeout); +} + +/** @type {RtdSubmodule} */ +export const semantiqRtdSubmodule = { + name: SUBMODULE_NAME, + getBidRequestData, + init, +}; + +submodule(MODULE_NAME, semantiqRtdSubmodule); diff --git a/modules/semantiqRtdProvider.md b/modules/semantiqRtdProvider.md new file mode 100644 index 00000000000..14a53d7dab7 --- /dev/null +++ b/modules/semantiqRtdProvider.md @@ -0,0 +1,46 @@ +# Overview + +**Module Name:** Semantiq Rtd Provider +**Module Type:** Rtd Provider +**Maintainer:** [Audienzz](https://audienzz.com) + +## Description + +This module retrieves real-time data from the SemantIQ service and populates ORTB data. + +You need to obtain a company ID from [Audienzz](https://audienzz.com) for the module to function properly. Contact [service@audienzz.ch](mailto:service@audienzz.ch) for details. + +## Integration + +1. Include the module into your `Prebid.js` build. + + ```sh + gulp build --modules='rtdModule,semantiqRtdProvider,...' + ``` + +1. Configure the module via `pbjs.setConfig`. + + ```js + pbjs.setConfig({ + ... + realTimeData: { + dataProviders: [ + { + name: 'semantiq', + waitForIt: true, + params: { + companyId: 12345, + timeout: 1000, + }, + }, + ], + }, + }); + ``` + +## Parameters + +| Name | Required | Description | Type | Default value | Example | +| --------- | -------- | ------------------------------------------------------------ | -------- | ------------- | --------------------- | +| companyId | Yes | Company ID obtained from [Audienzz](https://audienzz.com). | number | - | 12345 | +| timeout | No | The maximum time to wait for a response in milliseconds. | number | 1000 | 3000 | diff --git a/test/spec/modules/semantiqRtdProvider_spec.js b/test/spec/modules/semantiqRtdProvider_spec.js new file mode 100644 index 00000000000..de285297cbb --- /dev/null +++ b/test/spec/modules/semantiqRtdProvider_spec.js @@ -0,0 +1,366 @@ +import { convertSemantiqKeywordToOrtb, getOrtbKeywords, semantiqRtdSubmodule, storage } from '../../../modules/semantiqRtdProvider'; +import { expect } from 'chai'; +import { server } from '../../mocks/xhr.js'; +import * as utils from '../../../src/utils.js'; + +describe('semantiqRtdProvider', () => { + let clock; + let getDataFromLocalStorageStub; + let getWindowLocationStub; + + beforeEach(() => { + clock = sinon.useFakeTimers(); + getDataFromLocalStorageStub = sinon.stub(storage, 'getDataFromLocalStorage').returns(null); + getWindowLocationStub = sinon.stub(utils, 'getWindowLocation').returns(new URL('https://example.com/article')); + }); + + afterEach(() => { + clock.restore(); + getDataFromLocalStorageStub.restore(); + getWindowLocationStub.restore(); + }); + + describe('init', () => { + it('returns true on initialization', () => { + const initResult = semantiqRtdSubmodule.init(); + expect(initResult).to.be.true; + }); + }); + + describe('convertSemantiqKeywordToOrtb', () => { + it('converts SemantIQ keywords properly', () => { + expect(convertSemantiqKeywordToOrtb('foo', 'bar')).to.be.equal('foo=bar'); + expect(convertSemantiqKeywordToOrtb('foo', ['bar', 'baz'])).to.be.equal('foo=bar,foo=baz'); + }); + + it('uses none as a default value for empty keywords', () => { + expect(convertSemantiqKeywordToOrtb('foo', '')).to.be.equal('foo=none'); + expect(convertSemantiqKeywordToOrtb('foo', [])).to.be.equal('foo=none'); + }); + }); + + describe('getOrtbKeywords', () => { + it('returns an empty string if no keywords are provided', () => { + expect(getOrtbKeywords({})).to.be.equal(''); + }); + + it('converts keywords to ORTB format', () => { + expect(getOrtbKeywords({ foo: 'bar', fizz: ['buzz', 'quz'], baz: '', xyz: [] })).to.be.equal('foo=bar,fizz=buzz,fizz=quz,baz=none,xyz=none'); + }); + }); + + describe('getBidRequestData', () => { + it('requests data with correct parameters', async () => { + const reqBidsConfigObj = { + adUnits: [{ bids: [{ bidder: 'appnexus' }] }], + }; + + semantiqRtdSubmodule.getBidRequestData( + reqBidsConfigObj, + () => undefined, + { params: {} }, + {} + ); + + server.requests[0].respond( + 200, + { 'Content-Type': 'application/json' }, + JSON.stringify({}) + ); + + const requestUrl = new URL(server.requests[0].url); + + expect(requestUrl.host).to.be.equal('api.adnz.co'); + expect(requestUrl.searchParams.get('url')).to.be.equal('https://example.com/article'); + expect(requestUrl.searchParams.get('tenantIds')).to.be.equal('1'); + }); + + it('allows to specify company ID as a parameter', async () => { + const reqBidsConfigObj = { + adUnits: [{ bids: [{ bidder: 'appnexus' }] }], + }; + + const onDoneSpy = sinon.spy(); + + const promise = new Promise((resolve) => semantiqRtdSubmodule.getBidRequestData( + reqBidsConfigObj, + () => { + onDoneSpy(); + resolve(); + }, + { params: { companyId: '13' } }, + {} + )); + + server.requests[0].respond( + 200, + { 'Content-Type': 'application/json' }, + JSON.stringify({}) + ); + + await promise; + + const requestUrl = new URL(server.requests[0].url); + + expect(requestUrl.searchParams.get('tenantIds')).to.be.equal('1,13'); + }); + + it('gets keywords from the cache if the data is present in the storage', async () => { + getDataFromLocalStorageStub.returns(JSON.stringify({ url: 'https://example.com/article', keywords: { sentiment: 'negative', ctx_segment: ['C001', 'C002'] } })); + + const reqBidsConfigObj = { + adUnits: [{ bids: [{ bidder: 'appnexus' }] }], + ortb2Fragments: { + global: {}, + } + }; + + const onDoneSpy = sinon.spy(); + + const promise = new Promise((resolve) => semantiqRtdSubmodule.getBidRequestData( + reqBidsConfigObj, + () => { + onDoneSpy(); + resolve(); + }, + { params: {} }, + {} + )); + + await promise; + + expect(onDoneSpy.calledOnce).to.be.true; + expect(server.requests).to.have.lengthOf(0); + expect(reqBidsConfigObj.ortb2Fragments.global).to.deep.equal({ site: { keywords: 'sentiment=negative,ctx_segment=C001,ctx_segment=C002' } }); + }); + + it('requests keywords from the server if the URL of the page is different from the cached one', async () => { + getDataFromLocalStorageStub.returns(JSON.stringify({ url: 'https://example.com/article', keywords: { cached: 'true' } })); + getWindowLocationStub.returns(new URL('https://example.com/another-article')); + + const reqBidsConfigObj = { + adUnits: [{ bids: [{ bidder: 'appnexus' }] }], + ortb2Fragments: { + global: {}, + } + }; + + const onDoneSpy = sinon.spy(); + + const promise = new Promise((resolve) => semantiqRtdSubmodule.getBidRequestData( + reqBidsConfigObj, + () => { + onDoneSpy(); + resolve(); + }, + { params: {} }, + {} + )); + + server.requests[0].respond( + 200, + { 'Content-Type': 'application/json' }, + JSON.stringify({ server: 'true' }) + ); + + await promise; + + expect(onDoneSpy.calledOnce).to.be.true; + expect(reqBidsConfigObj.ortb2Fragments.global).to.deep.equal({ site: { keywords: 'server=true' } }); + }); + + it('requests keywords from the server if the cached data is missing in the storage', async () => { + const reqBidsConfigObj = { + adUnits: [{ bids: [{ bidder: 'appnexus' }] }], + ortb2Fragments: { + global: {}, + }, + }; + + const onDoneSpy = sinon.spy(); + + const promise = new Promise((resolve) => semantiqRtdSubmodule.getBidRequestData( + reqBidsConfigObj, + () => { + onDoneSpy(); + resolve(); + }, + { params: {} }, + {} + )); + + server.requests[0].respond( + 200, + { 'Content-Type': 'application/json' }, + JSON.stringify({ sentiment: 'negative', ctx_segment: ['C001', 'C002'] }) + ); + + await promise; + + expect(onDoneSpy.calledOnce).to.be.true; + expect(reqBidsConfigObj.ortb2Fragments.global).to.deep.equal({ site: { keywords: 'sentiment=negative,ctx_segment=C001,ctx_segment=C002' } }); + }); + + it('merges ORTB site keywords if they are present', async () => { + const reqBidsConfigObj = { + adUnits: [{ bids: [{ bidder: 'appnexus' }] }], + ortb2Fragments: { + global: { + site: { + keywords: 'iab_category=politics', + } + }, + }, + }; + + const onDoneSpy = sinon.spy(); + + const promise = new Promise((resolve) => semantiqRtdSubmodule.getBidRequestData( + reqBidsConfigObj, + () => { + onDoneSpy(); + resolve(); + }, + { params: {} }, + {} + )); + + server.requests[0].respond( + 200, + { 'Content-Type': 'application/json' }, + JSON.stringify({ sentiment: 'negative', ctx_segment: ['C001', 'C002'] }) + ); + + await promise; + + expect(onDoneSpy.calledOnce).to.be.true; + expect(reqBidsConfigObj.ortb2Fragments.global).to.deep.equal({ site: { keywords: 'iab_category=politics,sentiment=negative,ctx_segment=C001,ctx_segment=C002' } }); + }); + + it("won't modify ortb2 if if no ad units are provided", async () => { + const reqBidsConfigObj = { + adUnits: [], + ortb2Fragments: {} + }; + + const onDoneSpy = sinon.spy(); + + semantiqRtdSubmodule.getBidRequestData(reqBidsConfigObj, onDoneSpy, { params: {} }, {}); + + expect(onDoneSpy.calledOnce).to.be.true; + expect(reqBidsConfigObj).to.deep.equal({ + adUnits: [], + ortb2Fragments: {} + }); + }); + + it("won't modify ortb2 if response is broken", async () => { + const reqBidsConfigObj = { + adUnits: [{ bids: [{ bidder: 'appnexus' }] }], + ortb2Fragments: {} + }; + const onDoneSpy = sinon.spy(); + + const promise = new Promise((resolve) => { + semantiqRtdSubmodule.getBidRequestData(reqBidsConfigObj, () => { + onDoneSpy(); + resolve(); + }, { params: {} }, {}); + }); + + server.requests[0].respond( + 200, + { 'Content-Type': 'application/json' }, + '{' + ); + + await promise; + + expect(onDoneSpy.calledOnce).to.be.true; + expect(reqBidsConfigObj).to.deep.equal({ + adUnits: [{ bids: [{ bidder: 'appnexus' }] }], + ortb2Fragments: {} + }); + }); + + it("won't modify ortb2 if response status is not 200", async () => { + const reqBidsConfigObj = { + adUnits: [{ bids: [{ bidder: 'appnexus' }] }], + ortb2Fragments: {} + }; + const onDoneSpy = sinon.spy(); + + const promise = new Promise((resolve) => { + semantiqRtdSubmodule.getBidRequestData(reqBidsConfigObj, () => { + onDoneSpy(); + resolve(); + }, { params: {} }, {}); + }); + + server.requests[0].respond( + 204, + { 'Content-Type': 'application/json' }, + ); + + await promise; + + expect(onDoneSpy.calledOnce).to.be.true; + expect(reqBidsConfigObj).to.deep.equal({ + adUnits: [{ bids: [{ bidder: 'appnexus' }] }], + ortb2Fragments: {} + }); + }); + + it("won't modify ortb2 if an error occurs during the request", async () => { + const reqBidsConfigObj = { + adUnits: [{ bids: [{ bidder: 'appnexus' }] }], + ortb2Fragments: {} + }; + const onDoneSpy = sinon.spy(); + + const promise = new Promise((resolve) => { + semantiqRtdSubmodule.getBidRequestData(reqBidsConfigObj, () => { + onDoneSpy(); + resolve(); + }, { params: {} }, {}); + }); + + server.requests[0].respond( + 500, + { 'Content-Type': 'application/json' }, + '{}' + ); + + await promise; + + expect(onDoneSpy.calledOnce).to.be.true; + expect(reqBidsConfigObj).to.deep.equal({ + adUnits: [{ bids: [{ bidder: 'appnexus' }] }], + ortb2Fragments: {}, + }); + }); + + it("won't modify ortb2 if response time hits timeout", async () => { + const reqBidsConfigObj = { + adUnits: [{ bids: [{ bidder: 'appnexus' }] }], + ortb2Fragments: {}, + }; + const onDoneSpy = sinon.spy(); + + const promise = new Promise((resolve) => semantiqRtdSubmodule.getBidRequestData(reqBidsConfigObj, () => { + onDoneSpy(); + resolve(); + }, { params: { timeout: 500 } }, {})); + + clock.tick(510); + + await promise; + + expect(onDoneSpy.calledOnce).to.be.true; + expect(reqBidsConfigObj).to.deep.equal({ + adUnits: [{ bids: [{ bidder: 'appnexus' }] }], + ortb2Fragments: {}, + }); + }); + }); +});