Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Topics module: Initial Topics iframe implementation #8947

Merged
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 43 additions & 0 deletions integrationExamples/gpt/topics_frame.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
<!DOCTYPE html>
<html lang="en">
<head>
<title>Topics demo</title>
<meta charset="utf-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1">
<link href="favicon.ico" rel="shortcut icon">
<script>
async function getTopics() {
try {
if ('browsingTopics' in document && document.featurePolicy.allowsFeature('browsing-topics')) {
const topics = await document.browsingTopics();
console.log('Called iframe:', window.location.hostname, topics, '\nNumber of topics: ', topics.length);
return Promise.resolve(topics);
} else {
console.log('document.browsingTopics() not supported');
}

} catch (error) {
console.log('Error:', error);
}
}

document.addEventListener('DOMContentLoaded', async function () {
const topics = await getTopics()
const url = new URL(window.location.href);
const params = new URLSearchParams(url.search);
const message = JSON.stringify({
segment: {
domain: window.location.hostname,
topics,
bidder: params.get("bidder")
},
date: Date.now(),
});
window.parent.postMessage(message, '*');
pm-nitin-nimbalkar marked this conversation as resolved.
Show resolved Hide resolved
});
</script>
</head>
<body>
</body>
</html>
183 changes: 182 additions & 1 deletion modules/topicsFpdModule.js
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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]) => {
Expand Down Expand Up @@ -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: {
Expand All @@ -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();
patmmccann marked this conversation as resolved.
Show resolved Hide resolved
if (!HAS_GDPR_CONSENT || !HAS_DEVICE_ACCESS) {
logInfo('Topics Module : Consent string is required to fetch the topics from third party domains.');
return;
}
patmmccann marked this conversation as resolved.
Show resolved Hide resolved
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,
Expand Down
Loading