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 FPD module: initial release #8646

Merged
merged 8 commits into from
Jul 28, 2022
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
3 changes: 2 additions & 1 deletion modules/.submodules.json
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,8 @@
],
"fpdModule": [
"enrichmentFpdModule",
"validationFpdModule"
"validationFpdModule",
"topicsFpdModule"
]
},
"libraries": {
Expand Down
22 changes: 16 additions & 6 deletions modules/fpdModule/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@
*/
import { config } from '../../src/config.js';
import { module, getHook } from '../../src/hook.js';
import {logError} from '../../src/utils.js';
import {GreedyPromise} from '../../src/utils/promise.js';

let submodules = [];

Expand All @@ -17,19 +19,27 @@ export function reset() {

export function processFpd({global = {}, bidder = {}} = {}) {
let modConf = config.getConfig('firstPartyData') || {};

let result = GreedyPromise.resolve({global, bidder});
submodules.sort((a, b) => {
return ((a.queue || 1) - (b.queue || 1));
}).forEach(submodule => {
({global = global, bidder = bidder} = submodule.processFpd(modConf, {global, bidder}));
result = result.then(
({global, bidder}) => GreedyPromise.resolve(submodule.processFpd(modConf, {global, bidder}))
.catch((err) => {
logError(`Error in FPD module ${submodule.name}`, err);
return {};
})
.then((result) => ({global: result.global || global, bidder: result.bidder || bidder}))
);
});

return {global, bidder};
return result;
}

export function startAuctionHook(fn, req) {
Object.assign(req.ortb2Fragments, processFpd(req.ortb2Fragments));
fn.call(this, req);
processFpd(req.ortb2Fragments).then((ortb2Fragments) => {
Object.assign(req.ortb2Fragments, ortb2Fragments);
fn.call(this, req);
})
}

function setupHook() {
Expand Down
80 changes: 80 additions & 0 deletions modules/topicsFpdModule.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
import {logError, logWarn, mergeDeep} from '../src/utils.js';
import {getRefererInfo} from '../src/refererDetection.js';
import {submodule} from '../src/hook.js';
import {GreedyPromise} from '../src/utils/promise.js';

const TAXONOMIES = {
// map from topic taxonomyVersion to IAB segment taxonomy
'1': 600
}

function partitionBy(field, items) {
return items.reduce((partitions, item) => {
const key = item[field];
if (!partitions.hasOwnProperty(key)) partitions[key] = [];
partitions[key].push(item);
return partitions;
}, {});
}

export function getTopicsData(name, topics, taxonomies = TAXONOMIES) {
return Object.entries(partitionBy('taxonomyVersion', topics))
.filter(([taxonomyVersion]) => {
if (!taxonomies.hasOwnProperty(taxonomyVersion)) {
logWarn(`Unrecognized taxonomyVersion from Topics API: "${taxonomyVersion}"; topic will be ignored`);
return false;
}
return true;
}).flatMap(([taxonomyVersion, topics]) =>
Object.entries(partitionBy('modelVersion', topics))
.map(([modelVersion, topics]) => {
const datum = {
ext: {
segtax: taxonomies[taxonomyVersion],
segclass: modelVersion
},
segment: topics.map((topic) => ({id: topic.topic.toString()}))
};
if (name != null) {
datum.name = name;
}
return datum;
})
);
}

export function getTopics(doc = document) {
let topics = null;
try {
if ('browsingTopics' in doc && doc.featurePolicy.allowsFeature('browsing-topics')) {
topics = GreedyPromise.resolve(doc.browsingTopics());
}
} catch (e) {
logError('Could not call topics API', e);
}
if (topics == null) {
topics = GreedyPromise.resolve([]);
}
return topics;
}

const topicsData = getTopics().then((topics) => getTopicsData(getRefererInfo().domain, topics));
Copy link
Collaborator

@patmmccann patmmccann Jul 27, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It seems we should potentially use the domain of the url in https://developer.mozilla.org/en-US/docs/Web/API/Document/currentScript

Or perhaps a configuration override is sufficient.

Pubishers may wish to indicate the breadth of the footprint here, eg hearst.com instead of somemagazine.com or cafemedia.com instead of cafedelites.com.

The topics api results are reporteed to be dependent on the domain of the script which calls it.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not sure I understand - do you mean that topics will return different results if you use <script src="//rubicon.com/prebid.js" /> compared <script src="//appnexus.com/prebid.js" />? that seems insane.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That's how it is described

Copy link
Collaborator

@patmmccann patmmccann Jul 28, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So it appears there's been some clarification:

"A design goal of the Topics API is to enable interest-based advertising without the sharing of information to more entities than is currently possible with third-party cookies. The Topics API proposes that topics can only be returned for API callers that have already observed them, within a limited timeframe.

Key Term
A Topics API caller is the entity that calls the document.browsingTopics() JavaScript method, and will use the topics returned by the method to help select relevant ads. Typically, a call to document.browsingTopics() would be from code included in a site from a third party such as an adtech platform. The browser determines the caller from the site of the current document. So, if you're a third party on a page, make sure you call the API from an iframe that your site owns."

This suggests only if you call the api from inside a frame and do the post-message will you get different behavior

Let's keep that for a follow up and cancel this change request to this pr

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@dgirardi testing successful


export function processFpd(config, {global}, {data = topicsData} = {}) {
return data.then((data) => {
if (data.length) {
mergeDeep(global, {
user: {
data
}
});
}
return {global};
});
}

submodule('firstPartyData', {
name: 'topics',
queue: 1,
processFpd
});
127 changes: 69 additions & 58 deletions test/spec/modules/fpdModule_spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,6 @@ import {processFpd, registerSubmodules, startAuctionHook, reset} from 'modules/f
import * as enrichmentModule from 'modules/enrichmentFpdModule.js';
import * as validationModule from 'modules/validationFpdModule/index.js';

let enrichments = {...enrichmentModule};
let validations = {...validationModule};

describe('the first party data module', function () {
afterEach(function () {
config.resetConfig();
Expand All @@ -18,21 +15,37 @@ describe('the first party data module', function () {
global: {key: 'value'},
bidder: {A: {bkey: 'bvalue'}}
}
before(() => {
beforeEach(() => {
reset();
});

it('should run ortb2Fragments through fpd submodules', () => {
registerSubmodules({
name: 'test',
queue: 2,
processFpd: function () {
return mockFpd;
}
});
})
const req = {ortb2Fragments: {}};
return new Promise((resolve) => startAuctionHook(resolve, req))
.then(() => {
expect(req.ortb2Fragments).to.eql(mockFpd);
})
});

it('should run ortb2Fragments through fpd submodules', () => {
it('should work with fpd submodules that return promises', () => {
registerSubmodules({
name: 'test',
processFpd: function () {
return Promise.resolve(mockFpd);
}
});
const req = {ortb2Fragments: {}};
startAuctionHook(() => null, req);
expect(req.ortb2Fragments).to.eql(mockFpd);
return new Promise((resolve) => {
startAuctionHook(resolve, req);
}).then(() => {
expect(req.ortb2Fragments).to.eql(mockFpd);
});
});
});

Expand Down Expand Up @@ -79,7 +92,6 @@ describe('the first party data module', function () {
});

it('filters ortb2 data that is set', function () {
let validated;
const global = {
user: {
data: {},
Expand Down Expand Up @@ -113,42 +125,42 @@ describe('the first party data module', function () {
width = 1120;
height = 750;

({global: validated} = processFpd({global}));
expect(validated.site.ref).to.equal(getRefererInfo().ref || undefined);
expect(validated.site.page).to.equal('https://www.domain.com/path?query=12345');
expect(validated.site.domain).to.equal('domain.com');
expect(validated.site.content.data).to.deep.equal([{segment: [{id: 'test'}], name: 'bar'}]);
expect(validated.user.data).to.be.undefined;
expect(validated.device).to.deep.to.equal({w: 1, h: 1});
expect(validated.site.keywords).to.be.undefined;
return processFpd({global}).then(({global: validated}) => {
expect(validated.site.ref).to.equal(getRefererInfo().ref || undefined);
expect(validated.site.page).to.equal('https://www.domain.com/path?query=12345');
expect(validated.site.domain).to.equal('domain.com');
expect(validated.site.content.data).to.deep.equal([{segment: [{id: 'test'}], name: 'bar'}]);
expect(validated.user.data).to.be.undefined;
expect(validated.device).to.deep.to.equal({w: 1, h: 1});
expect(validated.site.keywords).to.be.undefined;
});
});

it('should not overwrite existing data with default settings', function () {
let validated;
const global = {
site: {
ref: 'https://referer.com'
}
};

({global: validated} = processFpd({global}));
expect(validated.site.ref).to.equal('https://referer.com');
return processFpd({global}).then(({global: validated}) => {
expect(validated.site.ref).to.equal('https://referer.com');
});
});

it('should allow overwrite default data with setConfig', function () {
let validated;
const global = {
site: {
ref: 'https://referer.com'
}
};

({global: validated} = processFpd({global}));
expect(validated.site.ref).to.equal('https://referer.com');
return processFpd({global}).then(({global: validated}) => {
expect(validated.site.ref).to.equal('https://referer.com');
});
});

it('should filter all data', function () {
let validated;
let global = {
imp: [],
site: {
Expand Down Expand Up @@ -179,15 +191,13 @@ describe('the first party data module', function () {
adServerCurrency: 'USD'
}
};

config.setConfig({'firstPartyData': {skipEnrichments: true}});

({global: validated} = processFpd({global}));
expect(validated).to.deep.equal({});
return processFpd({global}).then(({global: validated}) => {
expect(validated).to.deep.equal({});
});
});

it('should add enrichments but not alter any arbitrary ortb2 data', function () {
let validated;
let global = {
site: {
ext: {
Expand All @@ -205,12 +215,12 @@ describe('the first party data module', function () {
},
cur: ['USD']
};

({global: validated} = processFpd({global}));
expect(validated.site.ref).to.equal(getRefererInfo().referer);
expect(validated.site.ext.data).to.deep.equal({inventory: ['value1']});
expect(validated.user.ext.data).to.deep.equal({visitor: ['value2']});
expect(validated.cur).to.deep.equal(['USD']);
return processFpd({global}).then(({global: validated}) => {
expect(validated.site.ref).to.equal(getRefererInfo().referer);
expect(validated.site.ext.data).to.deep.equal({inventory: ['value1']});
expect(validated.user.ext.data).to.deep.equal({visitor: ['value2']});
expect(validated.cur).to.deep.equal(['USD']);
})
});

it('should filter bidderConfig data', function () {
Expand All @@ -230,12 +240,13 @@ describe('the first party data module', function () {
}
};

const {bidder: validated} = processFpd({bidder});
expect(validated.bidderA).to.not.be.undefined;
expect(validated.bidderA.user.data).to.be.undefined;
expect(validated.bidderA.user.keywords).to.equal('test');
expect(validated.bidderA.site.keywords).to.equal('other');
expect(validated.bidderA.site.ref).to.equal('https://domain.com');
return processFpd({bidder}).then(({bidder: validated}) => {
expect(validated.bidderA).to.not.be.undefined;
expect(validated.bidderA.user.data).to.be.undefined;
expect(validated.bidderA.user.keywords).to.equal('test');
expect(validated.bidderA.site.keywords).to.equal('other');
expect(validated.bidderA.site.ref).to.equal('https://domain.com');
})
});

it('should not filter bidderConfig data as it is valid', function () {
Expand All @@ -255,17 +266,16 @@ describe('the first party data module', function () {
}
};

const {bidder: validated} = processFpd({bidder});

expect(validated.bidderA).to.not.be.undefined;
expect(validated.bidderA.user.data).to.deep.equal([{segment: [{id: 'data1_id'}], name: 'data1'}]);
expect(validated.bidderA.user.keywords).to.equal('test');
expect(validated.bidderA.site.keywords).to.equal('other');
expect(validated.bidderA.site.ref).to.equal('https://domain.com');
return processFpd({bidder}).then(({bidder: validated}) => {
expect(validated.bidderA).to.not.be.undefined;
expect(validated.bidderA.user.data).to.deep.equal([{segment: [{id: 'data1_id'}], name: 'data1'}]);
expect(validated.bidderA.user.keywords).to.equal('test');
expect(validated.bidderA.site.keywords).to.equal('other');
expect(validated.bidderA.site.ref).to.equal('https://domain.com');
});
});

it('should not set default values if skipEnrichments is turned on', function () {
let validated;
config.setConfig({'firstPartyData': {skipEnrichments: true}});

let global = {
Expand All @@ -281,15 +291,15 @@ describe('the first party data module', function () {
}
};

({global: validated} = processFpd({global}));
expect(validated.device).to.be.undefined;
expect(validated.site.ref).to.be.undefined;
expect(validated.site.page).to.be.undefined;
expect(validated.site.domain).to.be.undefined;
return processFpd({global}).then(({global: validated}) => {
expect(validated.device).to.be.undefined;
expect(validated.site.ref).to.be.undefined;
expect(validated.site.page).to.be.undefined;
expect(validated.site.domain).to.be.undefined;
});
});

it('should not validate ortb2 data if skipValidations is turned on', function () {
let validated;
config.setConfig({'firstPartyData': {skipValidations: true}});

let global = {
Expand All @@ -304,8 +314,9 @@ describe('the first party data module', function () {
}
};

({global: validated} = processFpd({global}));
expect(validated.user.data).to.deep.equal([{segment: [{id: 'nonfiltered'}]}]);
return processFpd({global}).then(({global: validated}) => {
expect(validated.user.data).to.deep.equal([{segment: [{id: 'nonfiltered'}]}]);
});
});
});
});
Loading