From 07f480966a1c81b93f81532de0aa95fbbe7c003b Mon Sep 17 00:00:00 2001
From: sebastienrufiange <131205907+sebastienrufiange@users.noreply.github.com>
Date: Thu, 11 Jan 2024 11:44:31 -0500
Subject: [PATCH] Contxtful RTD Provider: Initial Release (#10550)
* feat: added contxtfulRtdProvider
* fix: removed id in query param
* fix: googletag
* doc: typo
* fix: added contxtful in adloader
* doc: extra line
* fix: added connector config option
---
.../gpt/contxtfulRtdProvider_example.html | 91 ++++++++
modules/contxtfulRtdProvider.js | 150 +++++++++++++
modules/contxtfulRtdProvider.md | 65 ++++++
src/adloader.js | 3 +-
.../spec/modules/contxtfulRtdProvider_spec.js | 200 ++++++++++++++++++
5 files changed, 508 insertions(+), 1 deletion(-)
create mode 100644 integrationExamples/gpt/contxtfulRtdProvider_example.html
create mode 100644 modules/contxtfulRtdProvider.js
create mode 100644 modules/contxtfulRtdProvider.md
create mode 100644 test/spec/modules/contxtfulRtdProvider_spec.js
diff --git a/integrationExamples/gpt/contxtfulRtdProvider_example.html b/integrationExamples/gpt/contxtfulRtdProvider_example.html
new file mode 100644
index 00000000000..29284de81a2
--- /dev/null
+++ b/integrationExamples/gpt/contxtfulRtdProvider_example.html
@@ -0,0 +1,91 @@
+
+
+
+
+
+
+
+
+
+ Contxtful RTD Provider
+
+
+
+
+
\ No newline at end of file
diff --git a/modules/contxtfulRtdProvider.js b/modules/contxtfulRtdProvider.js
new file mode 100644
index 00000000000..69ff3c85079
--- /dev/null
+++ b/modules/contxtfulRtdProvider.js
@@ -0,0 +1,150 @@
+/**
+ * Contxtful Technologies Inc
+ * This RTD module provides receptivity feature that can be accessed using the
+ * getReceptivity() function. The value returned by this function enriches the ad-units
+ * that are passed within the `getTargetingData` functions and GAM.
+ */
+
+import { submodule } from '../src/hook.js';
+import {
+ logInfo,
+ logError,
+ isStr,
+ isEmptyStr,
+ buildUrl,
+} from '../src/utils.js';
+import { loadExternalScript } from '../src/adloader.js';
+
+const MODULE_NAME = 'contxtful';
+const MODULE = `${MODULE_NAME}RtdProvider`;
+
+const CONTXTFUL_RECEPTIVITY_DOMAIN = 'api.receptivity.io';
+
+let initialReceptivity = null;
+let contxtfulModule = null;
+
+/**
+ * Init function used to start sub module
+ * @param { { params: { version: String, customer: String, hostname: String } } } config
+ * @return { Boolean }
+ */
+function init(config) {
+ logInfo(MODULE, 'init', config);
+ initialReceptivity = null;
+ contxtfulModule = null;
+
+ try {
+ const {version, customer, hostname} = extractParameters(config);
+ initCustomer(version, customer, hostname);
+ return true;
+ } catch (error) {
+ logError(MODULE, error);
+ return false;
+ }
+}
+
+/**
+ * Extract required configuration for the sub module.
+ * validate that all required configuration are present and are valid.
+ * Throws an error if any config is missing of invalid.
+ * @param { { params: { version: String, customer: String, hostname: String } } } config
+ * @return { { version: String, customer: String, hostname: String } }
+ * @throws params.{name} should be a non-empty string
+ */
+function extractParameters(config) {
+ const version = config?.params?.version;
+ if (!isStr(version) || isEmptyStr(version)) {
+ throw Error(`${MODULE}: params.version should be a non-empty string`);
+ }
+
+ const customer = config?.params?.customer;
+ if (!isStr(customer) || isEmptyStr(customer)) {
+ throw Error(`${MODULE}: params.customer should be a non-empty string`);
+ }
+
+ const hostname = config?.params?.hostname || CONTXTFUL_RECEPTIVITY_DOMAIN;
+
+ return {version, customer, hostname};
+}
+
+/**
+ * Initialize sub module for a customer.
+ * This will load the external resources for the sub module.
+ * @param { String } version
+ * @param { String } customer
+ * @param { String } hostname
+ */
+function initCustomer(version, customer, hostname) {
+ const CONNECTOR_URL = buildUrl({
+ protocol: 'https',
+ host: hostname,
+ pathname: `/${version}/prebid/${customer}/connector/p.js`,
+ });
+
+ const externalScript = loadExternalScript(CONNECTOR_URL, MODULE_NAME);
+ addExternalScriptEventListener(externalScript);
+}
+
+/**
+ * Add event listener to the script tag for the expected events from the external script.
+ * @param { HTMLScriptElement } script
+ */
+function addExternalScriptEventListener(script) {
+ if (!script) {
+ return;
+ }
+
+ script.addEventListener('initialReceptivity', ({ detail }) => {
+ let receptivityState = detail?.ReceptivityState;
+ if (isStr(receptivityState) && !isEmptyStr(receptivityState)) {
+ initialReceptivity = receptivityState;
+ }
+ });
+
+ script.addEventListener('rxEngineIsReady', ({ detail: api }) => {
+ contxtfulModule = api;
+ });
+}
+
+/**
+ * Return current receptivity.
+ * @return { { ReceptivityState: String } }
+ */
+function getReceptivity() {
+ return {
+ ReceptivityState: contxtfulModule?.GetReceptivity()?.ReceptivityState || initialReceptivity
+ };
+}
+
+/**
+ * Set targeting data for ad server
+ * @param { [String] } adUnits
+ * @param {*} _config
+ * @param {*} _userConsent
+* @return {{ code: { ReceptivityState: String } }}
+ */
+function getTargetingData(adUnits, _config, _userConsent) {
+ logInfo(MODULE, 'getTargetingData');
+ if (!adUnits) {
+ return {};
+ }
+
+ const receptivity = getReceptivity();
+ if (!receptivity?.ReceptivityState) {
+ return {};
+ }
+
+ return adUnits.reduce((targets, code) => {
+ targets[code] = receptivity;
+ return targets;
+ }, {});
+}
+
+export const contxtfulSubmodule = {
+ name: MODULE_NAME,
+ init,
+ extractParameters,
+ getTargetingData,
+};
+
+submodule('realTimeData', contxtfulSubmodule);
diff --git a/modules/contxtfulRtdProvider.md b/modules/contxtfulRtdProvider.md
new file mode 100644
index 00000000000..dfefca2067a
--- /dev/null
+++ b/modules/contxtfulRtdProvider.md
@@ -0,0 +1,65 @@
+# Overview
+
+**Module Name:** Contxtful RTD Provider
+**Module Type:** RTD Provider
+**Maintainer:** [prebid@contxtful.com](mailto:prebid@contxtful.com)
+
+# Description
+
+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 contact [prebid@contxtful.com](mailto:prebid@contxtful.com).
+
+# Configuration
+
+## Build Instructions
+
+To incorporate this module into your `prebid.js`, compile the module using the following command:
+
+```sh
+gulp build --modules=contxtfulRtdProvider,
+```
+
+## Module Configuration
+
+Configure the `contxtfulRtdProvider` by passing the required settings through the `setConfig` function in `prebid.js`.
+
+```js
+import pbjs from 'prebid.js';
+
+pbjs.setConfig({
+ "realTimeData": {
+ "auctionDelay": 1000,
+ "dataProviders": [
+ {
+ "name": "contxtful",
+ "waitForIt": true,
+ "params": {
+ "version": "",
+ "customer": ""
+ }
+ }
+ ]
+ }
+});
+```
+
+### Configuration Parameters
+
+| Name | Type | Scope | Description |
+|------------|----------|----------|-------------------------------------------|
+| `version` | `string` | Required | Specifies the API version of Contxtful. |
+| `customer` | `string` | Required | Your unique customer identifier. |
+
+# Usage
+
+The `contxtfulRtdProvider` module loads an external JavaScript file and authenticates with Contxtful APIs. The `getTargetingData` function then adds a `ReceptivityState` to each ad slot, which can have one of two values: `Receptive` or `NonReceptive`.
+
+```json
+{
+ "adUnitCode1": { "ReceptivityState": "Receptive" },
+ "adUnitCode2": { "ReceptivityState": "NonReceptive" }
+}
+```
+
+This module also integrates seamlessly with Google Ad Manager, ensuring that the `ReceptivityState` is available as early as possible in the ad serving process.
\ No newline at end of file
diff --git a/src/adloader.js b/src/adloader.js
index d1dca9627d8..664fd03d673 100644
--- a/src/adloader.js
+++ b/src/adloader.js
@@ -29,7 +29,8 @@ const _approvedLoadExternalJSList = [
'geoedge',
'mediafilter',
'qortex',
- 'dynamicAdBoost'
+ 'dynamicAdBoost',
+ 'contxtful'
]
/**
diff --git a/test/spec/modules/contxtfulRtdProvider_spec.js b/test/spec/modules/contxtfulRtdProvider_spec.js
new file mode 100644
index 00000000000..541c0e6e6dd
--- /dev/null
+++ b/test/spec/modules/contxtfulRtdProvider_spec.js
@@ -0,0 +1,200 @@
+import { contxtfulSubmodule } from '../../../modules/contxtfulRtdProvider.js';
+import { expect } from 'chai';
+import { loadExternalScriptStub } from 'test/mocks/adloaderStub.js';
+
+import * as events from '../../../src/events';
+
+const _ = null;
+const VERSION = 'v1';
+const CUSTOMER = 'CUSTOMER';
+const CONTXTFUL_CONNECTOR_ENDPOINT = `https://api.receptivity.io/${VERSION}/prebid/${CUSTOMER}/connector/p.js`;
+const INITIAL_RECEPTIVITY = { ReceptivityState: 'INITIAL_RECEPTIVITY' };
+const INITIAL_RECEPTIVITY_EVENT = new CustomEvent('initialReceptivity', { detail: INITIAL_RECEPTIVITY });
+
+const CONTXTFUL_API = { GetReceptivity: sinon.stub() }
+const RX_ENGINE_IS_READY_EVENT = new CustomEvent('rxEngineIsReady', {detail: CONTXTFUL_API});
+
+function buildInitConfig(version, customer) {
+ return {
+ name: 'contxtful',
+ params: {
+ version,
+ customer,
+ },
+ };
+}
+
+describe('contxtfulRtdProvider', function () {
+ let sandbox = sinon.sandbox.create();
+ let loadExternalScriptTag;
+ let eventsEmitSpy;
+
+ beforeEach(() => {
+ loadExternalScriptTag = document.createElement('script');
+ loadExternalScriptStub.callsFake((_url, _moduleName) => loadExternalScriptTag);
+
+ CONTXTFUL_API.GetReceptivity.reset();
+
+ eventsEmitSpy = sandbox.spy(events, ['emit']);
+ });
+
+ afterEach(function () {
+ delete window.Contxtful;
+ sandbox.restore();
+ });
+
+ describe('extractParameters with invalid configuration', () => {
+ const {
+ params: { customer, version },
+ } = buildInitConfig(VERSION, CUSTOMER);
+ const theories = [
+ [
+ null,
+ 'params.version should be a non-empty string',
+ 'null object for config',
+ ],
+ [
+ {},
+ 'params.version should be a non-empty string',
+ 'empty object for config',
+ ],
+ [
+ { customer },
+ 'params.version should be a non-empty string',
+ 'customer only in config',
+ ],
+ [
+ { version },
+ 'params.customer should be a non-empty string',
+ 'version only in config',
+ ],
+ [
+ { customer, version: '' },
+ 'params.version should be a non-empty string',
+ 'empty string for version',
+ ],
+ [
+ { customer: '', version },
+ 'params.customer should be a non-empty string',
+ 'empty string for customer',
+ ],
+ [
+ { customer: '', version: '' },
+ 'params.version should be a non-empty string',
+ 'empty string for version & customer',
+ ],
+ ];
+
+ theories.forEach(([params, expectedErrorMessage, _description]) => {
+ const config = { name: 'contxtful', params };
+ it('throws the expected error', () => {
+ expect(() => contxtfulSubmodule.extractParameters(config)).to.throw(
+ expectedErrorMessage
+ );
+ });
+ });
+ });
+
+ describe('initialization with invalid config', function () {
+ it('returns false', () => {
+ expect(contxtfulSubmodule.init({})).to.be.false;
+ });
+ });
+
+ describe('initialization with valid config', function () {
+ it('returns true when initializing', () => {
+ const config = buildInitConfig(VERSION, CUSTOMER);
+ expect(contxtfulSubmodule.init(config)).to.be.true;
+ });
+
+ it('loads contxtful module script asynchronously', (done) => {
+ contxtfulSubmodule.init(buildInitConfig(VERSION, CUSTOMER));
+
+ setTimeout(() => {
+ expect(loadExternalScriptStub.calledOnce).to.be.true;
+ expect(loadExternalScriptStub.args[0][0]).to.equal(
+ CONTXTFUL_CONNECTOR_ENDPOINT
+ );
+ done();
+ }, 10);
+ });
+ });
+
+ describe('load external script return falsy', function () {
+ it('returns true when initializing', () => {
+ loadExternalScriptStub.callsFake(() => {});
+ const config = buildInitConfig(VERSION, CUSTOMER);
+ expect(contxtfulSubmodule.init(config)).to.be.true;
+ });
+ });
+
+ describe('rxEngine from external script', function () {
+ it('use rxEngine api to get receptivity', () => {
+ contxtfulSubmodule.init(buildInitConfig(VERSION, CUSTOMER));
+ loadExternalScriptTag.dispatchEvent(RX_ENGINE_IS_READY_EVENT);
+
+ contxtfulSubmodule.getTargetingData(['ad-slot']);
+
+ expect(CONTXTFUL_API.GetReceptivity.calledOnce).to.be.true;
+ });
+ });
+
+ describe('initial receptivity is not dispatched', function () {
+ it('does not initialize receptivity value', () => {
+ contxtfulSubmodule.init(buildInitConfig(VERSION, CUSTOMER));
+
+ let targetingData = contxtfulSubmodule.getTargetingData(['ad-slot']);
+ expect(targetingData).to.deep.equal({});
+ });
+ });
+
+ describe('initial receptivity is invalid', function () {
+ const theories = [
+ [new Event('initialReceptivity'), 'event without details'],
+ [new CustomEvent('initialReceptivity', { }), 'custom event without details'],
+ [new CustomEvent('initialReceptivity', { detail: {} }), 'custom event with invalid details'],
+ [new CustomEvent('initialReceptivity', { detail: { ReceptivityState: '' } }), 'custom event with details without ReceptivityState'],
+ ];
+
+ theories.forEach(([initialReceptivityEvent, _description]) => {
+ it('does not initialize receptivity value', () => {
+ contxtfulSubmodule.init(buildInitConfig(VERSION, CUSTOMER));
+ loadExternalScriptTag.dispatchEvent(initialReceptivityEvent);
+
+ let targetingData = contxtfulSubmodule.getTargetingData(['ad-slot']);
+ expect(targetingData).to.deep.equal({});
+ });
+ })
+ });
+
+ describe('getTargetingData', function () {
+ const theories = [
+ [undefined, {}, 'undefined ad-slots'],
+ [[], {}, 'empty ad-slots'],
+ [
+ ['ad-slot'],
+ { 'ad-slot': { ReceptivityState: 'INITIAL_RECEPTIVITY' } },
+ 'single ad-slot',
+ ],
+ [
+ ['ad-slot-1', 'ad-slot-2'],
+ {
+ 'ad-slot-1': { ReceptivityState: 'INITIAL_RECEPTIVITY' },
+ 'ad-slot-2': { ReceptivityState: 'INITIAL_RECEPTIVITY' },
+ },
+ 'many ad-slots',
+ ],
+ ];
+
+ theories.forEach(([adUnits, expected, _description]) => {
+ it('adds "ReceptivityState" to the adUnits', function () {
+ contxtfulSubmodule.init(buildInitConfig(VERSION, CUSTOMER));
+ loadExternalScriptTag.dispatchEvent(INITIAL_RECEPTIVITY_EVENT);
+
+ expect(contxtfulSubmodule.getTargetingData(adUnits)).to.deep.equal(
+ expected
+ );
+ });
+ });
+ });
+});