diff --git a/integrationExamples/gpt/topics_frame.html b/integrationExamples/gpt/topics_frame.html
new file mode 100644
index 00000000000..7a12b030a6a
--- /dev/null
+++ b/integrationExamples/gpt/topics_frame.html
@@ -0,0 +1,43 @@
+
+
+
+ Topics demo
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/modules/topicsFpdModule.js b/modules/topicsFpdModule.js
index dbd1d3e90e9..f5a3c0a2c70 100644
--- a/modules/topicsFpdModule.js
+++ b/modules/topicsFpdModule.js
@@ -1,8 +1,31 @@
-import {logError, logWarn, mergeDeep} from '../src/utils.js';
+import {logError, logWarn, mergeDeep, isEmpty, safeJSONParse, logInfo, hasDeviceAccess} from '../src/utils.js';
import {getRefererInfo} from '../src/refererDetection.js';
import {submodule} from '../src/hook.js';
import {GreedyPromise} from '../src/utils/promise.js';
+import {config} from '../src/config.js';
+import {getCoreStorageManager} from '../src/storageManager.js';
+import {includes} from '../src/polyfill.js';
+import {gdprDataHandler} from '../src/adapterManager.js';
+const MODULE_NAME = 'topicsFpd';
+const DEFAULT_EXPIRATION_DAYS = 21;
+const TCF_REQUIRED_PURPOSES = ['1', '2', '3', '4'];
+let HAS_GDPR_CONSENT = true;
+let LOAD_TOPICS_INITIALISE = false;
+const HAS_DEVICE_ACCESS = hasDeviceAccess();
+
+const bidderIframeList = {
+ maxTopicCaller: 1,
+ bidders: [{
+ bidder: 'pubmatic',
+ iframeURL: 'https://ads.pubmatic.com/AdServer/js/topics/topics_frame.html'
+ }]
+}
+export const coreStorage = getCoreStorageManager(MODULE_NAME);
+export const topicStorageName = 'prebid:topics';
+export const lastUpdated = 'lastUpdated';
+
+const iframeLoadedURL = [];
const TAXONOMIES = {
// map from topic taxonomyVersion to IAB segment taxonomy
'1': 600
@@ -17,6 +40,20 @@ function partitionBy(field, items) {
}, {});
}
+/**
+ * function to get list of loaded Iframes calling Topics API
+ */
+function getLoadedIframeURL() {
+ return iframeLoadedURL;
+}
+
+/**
+ * function to set/push iframe in the list which is loaded to called topics API.
+ */
+function setLoadedIframeURL(url) {
+ return iframeLoadedURL.push(url);
+}
+
export function getTopicsData(name, topics, taxonomies = TAXONOMIES) {
return Object.entries(partitionBy('taxonomyVersion', topics))
.filter(([taxonomyVersion]) => {
@@ -61,7 +98,12 @@ export function getTopics(doc = document) {
const topicsData = getTopics().then((topics) => getTopicsData(getRefererInfo().domain, topics));
export function processFpd(config, {global}, {data = topicsData} = {}) {
+ if (!LOAD_TOPICS_INITIALISE) {
+ loadTopicsForBidders();
+ LOAD_TOPICS_INITIALISE = true;
+ }
return data.then((data) => {
+ data = [].concat(data, getCachedTopics()); // Add cached data in FPD data.
if (data.length) {
mergeDeep(global, {
user: {
@@ -73,6 +115,145 @@ export function processFpd(config, {global}, {data = topicsData} = {}) {
});
}
+/**
+ * function to fetch the cached topic data from storage for bidders and return it
+ */
+export function getCachedTopics() {
+ let cachedTopicData = [];
+ if (!HAS_GDPR_CONSENT || !HAS_DEVICE_ACCESS) {
+ return cachedTopicData;
+ }
+ const topics = config.getConfig('userSync.topics') || bidderIframeList;
+ const bidderList = topics.bidders || [];
+ let storedSegments = new Map(safeJSONParse(coreStorage.getDataFromLocalStorage(topicStorageName)));
+ storedSegments && storedSegments.forEach((value, cachedBidder) => {
+ // Check bidder exist in config for cached bidder data and then only retrieve the cached data
+ let bidderConfigObj = bidderList.find(({bidder}) => cachedBidder == bidder)
+ if (bidderConfigObj) {
+ if (!isCachedDataExpired(value[lastUpdated], bidderConfigObj?.expiry || DEFAULT_EXPIRATION_DAYS)) {
+ Object.keys(value).forEach((segData) => {
+ segData != lastUpdated && cachedTopicData.push(value[segData]);
+ })
+ } else {
+ // delete the specific bidder map from the store and store the updated maps
+ storedSegments.delete(cachedBidder);
+ coreStorage.setDataInLocalStorage(topicStorageName, JSON.stringify([...storedSegments]));
+ }
+ }
+ });
+ return cachedTopicData;
+}
+
+/**
+ * Recieve messages from iframe loaded for bidders to fetch topic
+ * @param {MessageEvent} evt
+ */
+export function receiveMessage(evt) {
+ if (evt && evt.data) {
+ try {
+ let data = safeJSONParse(evt.data);
+ if (includes(getLoadedIframeURL(), evt.origin) && data && data.segment && !isEmpty(data.segment.topics)) {
+ const {domain, topics, bidder} = data.segment;
+ const iframeTopicsData = getTopicsData(domain, topics)[0];
+ iframeTopicsData && storeInLocalStorage(bidder, iframeTopicsData);
+ }
+ } catch (err) { }
+ }
+}
+
+/**
+Function to store Topics data recieved from iframe in storage(name: "prebid:topics")
+* @param {Topics} topics
+*/
+export function storeInLocalStorage(bidder, topics) {
+ const storedSegments = new Map(safeJSONParse(coreStorage.getDataFromLocalStorage(topicStorageName)));
+ if (storedSegments.has(bidder)) {
+ storedSegments.get(bidder)[topics['ext']['segclass']] = topics;
+ storedSegments.get(bidder)[lastUpdated] = new Date().getTime();
+ storedSegments.set(bidder, storedSegments.get(bidder));
+ } else {
+ storedSegments.set(bidder, {[topics.ext.segclass]: topics, [lastUpdated]: new Date().getTime()})
+ }
+ coreStorage.setDataInLocalStorage(topicStorageName, JSON.stringify([...storedSegments]));
+}
+
+function isCachedDataExpired(storedTime, cacheTime) {
+ const _MS_PER_DAY = 1000 * 60 * 60 * 24;
+ const currentTime = new Date().getTime();
+ const daysDifference = Math.ceil((currentTime - storedTime) / _MS_PER_DAY);
+ return daysDifference > cacheTime;
+}
+
+/**
+* Function to get random bidders based on count passed with array of bidders
+**/
+function getRandomBidders(arr, count) {
+ return ([...arr].sort(() => 0.5 - Math.random())).slice(0, count)
+}
+
+/**
+ * function to add listener for message receiving from IFRAME
+ */
+function listenMessagesFromTopicIframe() {
+ window.addEventListener('message', receiveMessage, false);
+}
+
+function checkTCFv2(vendorData, requiredPurposes = TCF_REQUIRED_PURPOSES) {
+ const {gdprApplies, purpose} = vendorData;
+ if (!gdprApplies || !purpose) {
+ return true;
+ }
+ return requiredPurposes.map((purposeNo) => {
+ const purposeConsent = purpose.consents ? purpose.consents[purposeNo] : false;
+ if (purposeConsent) {
+ return true;
+ }
+ return false;
+ }).reduce((a, b) => a && b, true);
+}
+
+export function hasGDPRConsent() {
+ // Check for GDPR consent for purpose 1,2,3,4 and return false if consent has not been given
+ const gdprConsent = gdprDataHandler.getConsentData();
+ const hasGdpr = (gdprConsent && typeof gdprConsent.gdprApplies === 'boolean' && gdprConsent.gdprApplies) ? 1 : 0;
+ const gdprConsentString = hasGdpr ? gdprConsent.consentString : '';
+ if (hasGdpr) {
+ if ((!gdprConsentString || gdprConsentString === '') || !gdprConsent.vendorData) {
+ return false;
+ }
+ return checkTCFv2(gdprConsent.vendorData);
+ }
+ return true;
+}
+
+/**
+ * function to load the iframes of the bidder to load the topics data
+ */
+function loadTopicsForBidders() {
+ HAS_GDPR_CONSENT = hasGDPRConsent();
+ if (!HAS_GDPR_CONSENT || !HAS_DEVICE_ACCESS) {
+ logInfo('Topics Module : Consent string is required to fetch the topics from third party domains.');
+ return;
+ }
+ const topics = config.getConfig('userSync.topics') || bidderIframeList;
+ if (topics) {
+ listenMessagesFromTopicIframe();
+ const randomBidders = getRandomBidders(topics.bidders || [], topics.maxTopicCaller || 1)
+ randomBidders && randomBidders.forEach(({ bidder, iframeURL }) => {
+ if (bidder && iframeURL) {
+ let ifrm = document.createElement('iframe');
+ ifrm.name = 'ifrm_'.concat(bidder);
+ ifrm.src = ''.concat(iframeURL, '?bidder=').concat(bidder);
+ ifrm.style.display = 'none';
+ setLoadedIframeURL(new URL(iframeURL).origin);
+ iframeURL && window.document.documentElement.appendChild(ifrm);
+ }
+ })
+ } else {
+ logWarn(`Topics config not defined under userSync Object`);
+ }
+}
+
submodule('firstPartyData', {
name: 'topics',
queue: 1,
diff --git a/test/spec/modules/topicsFpdModule_spec.js b/test/spec/modules/topicsFpdModule_spec.js
index 3781768497b..69e7cc16ca8 100644
--- a/test/spec/modules/topicsFpdModule_spec.js
+++ b/test/spec/modules/topicsFpdModule_spec.js
@@ -1,5 +1,7 @@
-import {getTopics, getTopicsData, processFpd} from '../../../modules/topicsFpdModule.js';
-import {deepClone} from '../../../src/utils.js';
+import {getTopics, getTopicsData, processFpd, hasGDPRConsent, getCachedTopics, receiveMessage, topicStorageName} from '../../../modules/topicsFpdModule.js';
+import {deepClone, safeJSONParse} from '../../../src/utils.js';
+import {gdprDataHandler} from 'src/adapterManager.js';
+import {getCoreStorageManager} from 'src/storageManager.js';
describe('getTopicsData', () => {
function makeTopic(topic, modelv, taxv = '1') {
@@ -237,3 +239,175 @@ describe('processFpd', () => {
});
});
});
+
+describe('Topics Module GDPR consent check', () => {
+ let gdprDataHdlrStub;
+ beforeEach(() => {
+ gdprDataHdlrStub = sinon.stub(gdprDataHandler, 'getConsentData');
+ })
+
+ afterEach(() => {
+ gdprDataHdlrStub.restore();
+ });
+
+ it('should return false when GDPR is applied but consent string is not present', () => {
+ const consentString = '';
+ const consentConfig = {
+ consentString: consentString,
+ gdprApplies: true,
+ vendorData: {}
+ };
+ gdprDataHdlrStub.returns(consentConfig);
+ expect(hasGDPRConsent()).to.equal(false);
+ });
+
+ it("should return true when GDPR doesn't apply", () => {
+ const consentString = 'CPi8wgAPi8wgAADABBENCrCsAP_AAH_AAAAAISNB7D==';
+ const consentConfig = {
+ consentString: consentString,
+ gdprApplies: false,
+ vendorData: {}
+ };
+
+ gdprDataHdlrStub.returns(consentConfig);
+ expect(hasGDPRConsent()).to.equal(true);
+ });
+
+ it('should return true when GDPR is applied and purpose consent is true for all purpose[1,2,3,4]', () => {
+ const consentString = 'CPi8wgAPi8wgAADABBENCrCsAP_AAH_AAAAAISNB7D==';
+ const consentConfig = {
+ consentString: consentString,
+ gdprApplies: true,
+ vendorData: {
+ metadata: consentString,
+ gdprApplies: true,
+ purpose: {
+ consents: {
+ 1: true,
+ 2: true,
+ 3: true,
+ 4: true
+ }
+ }
+ }
+ };
+
+ gdprDataHdlrStub.returns(consentConfig);
+ expect(hasGDPRConsent()).to.equal(true);
+ });
+
+ it('should return false when GDPR is applied and purpose consent is false for one of the purpose[1,2,3,4]', () => {
+ const consentString = 'CPi8wgAPi8wgAADABBENCrCsAP_AAH_AAAAAISNB7D==';
+ const consentConfig = {
+ consentString: consentString,
+ gdprApplies: true,
+ vendorData: {
+ metadata: consentString,
+ gdprApplies: true,
+ purpose: {
+ consents: {
+ 1: true,
+ 2: true,
+ 3: true,
+ 4: false
+ }
+ }
+ }
+ };
+
+ gdprDataHdlrStub.returns(consentConfig);
+ expect(hasGDPRConsent()).to.equal(false);
+ });
+});
+
+describe('getCachedTopics()', () => {
+ const storage = getCoreStorageManager('topicsFpd');
+ const expected = [{
+ ext: {
+ segtax: 600,
+ segclass: '2206021246'
+ },
+ segment: [{
+ 'id': '243'
+ }, {
+ 'id': '265'
+ }],
+ name: 'ads.pubmatic.com'
+ }];
+ const consentString = 'CPi8wgAPi8wgAADABBENCrCsAP_AAH_AAAAAISNB7D==';
+ const consentConfig = {
+ consentString: consentString,
+ gdprApplies: true,
+ vendorData: {
+ metadata: consentString,
+ gdprApplies: true,
+ purpose: {
+ consents: {
+ 1: true,
+ 2: true,
+ 3: true,
+ 4: true
+ }
+ }
+ }
+ };
+ const mockData = [
+ {
+ name: 'domain',
+ segment: [{id: 123}]
+ },
+ {
+ name: 'domain',
+ segment: [{id: 321}],
+ }
+ ];
+
+ const evt = {
+ data: '{"segment":{"domain":"ads.pubmatic.com","topics":[{"configVersion":"chrome.1","modelVersion":"2206021246","taxonomyVersion":"1","topic":165,"version":"chrome.1:1:2206021246"}],"bidder":"pubmatic"},"date":1669743901858}',
+ origin: 'https://ads.pubmatic.com'
+ }
+
+ let gdprDataHdlrStub;
+ beforeEach(() => {
+ gdprDataHdlrStub = sinon.stub(gdprDataHandler, 'getConsentData');
+ });
+
+ afterEach(() => {
+ storage.removeDataFromLocalStorage(topicStorageName);
+ gdprDataHdlrStub.restore();
+ });
+
+ it('should return segments for bidder if GDPR consent is true and there is cached segments stored which is not expired', () => {
+ let storedSegments = '[["pubmatic",{"2206021246":{"ext":{"segtax":600,"segclass":"2206021246"},"segment":[{"id":"243"},{"id":"265"}],"name":"ads.pubmatic.com"},"lastUpdated":1669719242027}]]';
+ storage.setDataInLocalStorage(topicStorageName, storedSegments);
+ gdprDataHdlrStub.returns(consentConfig);
+ assert.deepEqual(getCachedTopics(), expected);
+ });
+
+ it('should return empty segments for bidder if GDPR consent is true and there is cached segments stored which is expired', () => {
+ let storedSegments = '[["pubmatic",{"2206021246":{"ext":{"segtax":600,"segclass":"2206021246"},"segment":[{"id":"243"},{"id":"265"}],"name":"ads.pubmatic.com"},"lastUpdated":1659719242027}]]';
+ storage.setDataInLocalStorage(topicStorageName, storedSegments);
+ gdprDataHdlrStub.returns(consentConfig);
+ assert.deepEqual(getCachedTopics(), []);
+ });
+
+ it('should stored segments if receiveMessage event is triggerred with segment data', () => {
+ return processFpd({}, {global: {}}, {data: Promise.resolve(mockData)})
+ .then(({global}) => {
+ receiveMessage(evt)
+ let segments = new Map(safeJSONParse(storage.getDataFromLocalStorage(topicStorageName)));
+ expect(segments.has('pubmatic')).to.equal(true);
+ });
+ });
+
+ it('should update stored segments if receiveMessage event is triggerred with segment data', () => {
+ let storedSegments = '[["pubmatic",{"2206021246":{"ext":{"segtax":600,"segclass":"2206021246"},"segment":[{"id":"243"},{"id":"265"}],"name":"ads.pubmatic.com"},"lastUpdated":1669719242027}]]';
+ storage.setDataInLocalStorage(topicStorageName, storedSegments);
+ return processFpd({}, {global: {}}, {data: Promise.resolve(mockData)})
+ .then(({global}) => {
+ receiveMessage(evt);
+ let segments = new Map(safeJSONParse(storage.getDataFromLocalStorage(topicStorageName)));
+ expect(segments.get('pubmatic')[2206021246].segment.length).to.equal(1);
+ });
+ });
+})