Skip to content

Commit abe46d5

Browse files
dgirardiJacobKlein26
authored andcommitted
Topics FPD module: initial release (prebid#8646)
* Topics FPD module * Small improvements * Map taxonomyVersion to segtax, modelVersion to segclass * Convert fpdModule & topicsFpdModule to use GreedyPromise
1 parent c6bdabd commit abe46d5

File tree

5 files changed

+406
-65
lines changed

5 files changed

+406
-65
lines changed

modules/.submodules.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -70,7 +70,8 @@
7070
],
7171
"fpdModule": [
7272
"enrichmentFpdModule",
73-
"validationFpdModule"
73+
"validationFpdModule",
74+
"topicsFpdModule"
7475
]
7576
},
7677
"libraries": {

modules/fpdModule/index.js

Lines changed: 16 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@
44
*/
55
import { config } from '../../src/config.js';
66
import { module, getHook } from '../../src/hook.js';
7+
import {logError} from '../../src/utils.js';
8+
import {GreedyPromise} from '../../src/utils/promise.js';
79

810
let submodules = [];
911

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

1820
export function processFpd({global = {}, bidder = {}} = {}) {
1921
let modConf = config.getConfig('firstPartyData') || {};
20-
22+
let result = GreedyPromise.resolve({global, bidder});
2123
submodules.sort((a, b) => {
2224
return ((a.queue || 1) - (b.queue || 1));
2325
}).forEach(submodule => {
24-
({global = global, bidder = bidder} = submodule.processFpd(modConf, {global, bidder}));
26+
result = result.then(
27+
({global, bidder}) => GreedyPromise.resolve(submodule.processFpd(modConf, {global, bidder}))
28+
.catch((err) => {
29+
logError(`Error in FPD module ${submodule.name}`, err);
30+
return {};
31+
})
32+
.then((result) => ({global: result.global || global, bidder: result.bidder || bidder}))
33+
);
2534
});
26-
27-
return {global, bidder};
35+
return result;
2836
}
2937

3038
export function startAuctionHook(fn, req) {
31-
Object.assign(req.ortb2Fragments, processFpd(req.ortb2Fragments));
32-
fn.call(this, req);
39+
processFpd(req.ortb2Fragments).then((ortb2Fragments) => {
40+
Object.assign(req.ortb2Fragments, ortb2Fragments);
41+
fn.call(this, req);
42+
})
3343
}
3444

3545
function setupHook() {

modules/topicsFpdModule.js

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
import {logError, logWarn, mergeDeep} from '../src/utils.js';
2+
import {getRefererInfo} from '../src/refererDetection.js';
3+
import {submodule} from '../src/hook.js';
4+
import {GreedyPromise} from '../src/utils/promise.js';
5+
6+
const TAXONOMIES = {
7+
// map from topic taxonomyVersion to IAB segment taxonomy
8+
'1': 600
9+
}
10+
11+
function partitionBy(field, items) {
12+
return items.reduce((partitions, item) => {
13+
const key = item[field];
14+
if (!partitions.hasOwnProperty(key)) partitions[key] = [];
15+
partitions[key].push(item);
16+
return partitions;
17+
}, {});
18+
}
19+
20+
export function getTopicsData(name, topics, taxonomies = TAXONOMIES) {
21+
return Object.entries(partitionBy('taxonomyVersion', topics))
22+
.filter(([taxonomyVersion]) => {
23+
if (!taxonomies.hasOwnProperty(taxonomyVersion)) {
24+
logWarn(`Unrecognized taxonomyVersion from Topics API: "${taxonomyVersion}"; topic will be ignored`);
25+
return false;
26+
}
27+
return true;
28+
}).flatMap(([taxonomyVersion, topics]) =>
29+
Object.entries(partitionBy('modelVersion', topics))
30+
.map(([modelVersion, topics]) => {
31+
const datum = {
32+
ext: {
33+
segtax: taxonomies[taxonomyVersion],
34+
segclass: modelVersion
35+
},
36+
segment: topics.map((topic) => ({id: topic.topic.toString()}))
37+
};
38+
if (name != null) {
39+
datum.name = name;
40+
}
41+
return datum;
42+
})
43+
);
44+
}
45+
46+
export function getTopics(doc = document) {
47+
let topics = null;
48+
try {
49+
if ('browsingTopics' in doc && doc.featurePolicy.allowsFeature('browsing-topics')) {
50+
topics = GreedyPromise.resolve(doc.browsingTopics());
51+
}
52+
} catch (e) {
53+
logError('Could not call topics API', e);
54+
}
55+
if (topics == null) {
56+
topics = GreedyPromise.resolve([]);
57+
}
58+
return topics;
59+
}
60+
61+
const topicsData = getTopics().then((topics) => getTopicsData(getRefererInfo().domain, topics));
62+
63+
export function processFpd(config, {global}, {data = topicsData} = {}) {
64+
return data.then((data) => {
65+
if (data.length) {
66+
mergeDeep(global, {
67+
user: {
68+
data
69+
}
70+
});
71+
}
72+
return {global};
73+
});
74+
}
75+
76+
submodule('firstPartyData', {
77+
name: 'topics',
78+
queue: 1,
79+
processFpd
80+
});

test/spec/modules/fpdModule_spec.js

Lines changed: 69 additions & 58 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,6 @@ import {processFpd, registerSubmodules, startAuctionHook, reset} from 'modules/f
55
import * as enrichmentModule from 'modules/enrichmentFpdModule.js';
66
import * as validationModule from 'modules/validationFpdModule/index.js';
77

8-
let enrichments = {...enrichmentModule};
9-
let validations = {...validationModule};
10-
118
describe('the first party data module', function () {
129
afterEach(function () {
1310
config.resetConfig();
@@ -18,21 +15,37 @@ describe('the first party data module', function () {
1815
global: {key: 'value'},
1916
bidder: {A: {bkey: 'bvalue'}}
2017
}
21-
before(() => {
18+
beforeEach(() => {
2219
reset();
20+
});
21+
22+
it('should run ortb2Fragments through fpd submodules', () => {
2323
registerSubmodules({
2424
name: 'test',
25-
queue: 2,
2625
processFpd: function () {
2726
return mockFpd;
2827
}
2928
});
30-
})
29+
const req = {ortb2Fragments: {}};
30+
return new Promise((resolve) => startAuctionHook(resolve, req))
31+
.then(() => {
32+
expect(req.ortb2Fragments).to.eql(mockFpd);
33+
})
34+
});
3135

32-
it('should run ortb2Fragments through fpd submodules', () => {
36+
it('should work with fpd submodules that return promises', () => {
37+
registerSubmodules({
38+
name: 'test',
39+
processFpd: function () {
40+
return Promise.resolve(mockFpd);
41+
}
42+
});
3343
const req = {ortb2Fragments: {}};
34-
startAuctionHook(() => null, req);
35-
expect(req.ortb2Fragments).to.eql(mockFpd);
44+
return new Promise((resolve) => {
45+
startAuctionHook(resolve, req);
46+
}).then(() => {
47+
expect(req.ortb2Fragments).to.eql(mockFpd);
48+
});
3649
});
3750
});
3851

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

8194
it('filters ortb2 data that is set', function () {
82-
let validated;
8395
const global = {
8496
user: {
8597
data: {},
@@ -113,42 +125,42 @@ describe('the first party data module', function () {
113125
width = 1120;
114126
height = 750;
115127

116-
({global: validated} = processFpd({global}));
117-
expect(validated.site.ref).to.equal(getRefererInfo().ref || undefined);
118-
expect(validated.site.page).to.equal('https://www.domain.com/path?query=12345');
119-
expect(validated.site.domain).to.equal('domain.com');
120-
expect(validated.site.content.data).to.deep.equal([{segment: [{id: 'test'}], name: 'bar'}]);
121-
expect(validated.user.data).to.be.undefined;
122-
expect(validated.device).to.deep.to.equal({w: 1, h: 1});
123-
expect(validated.site.keywords).to.be.undefined;
128+
return processFpd({global}).then(({global: validated}) => {
129+
expect(validated.site.ref).to.equal(getRefererInfo().ref || undefined);
130+
expect(validated.site.page).to.equal('https://www.domain.com/path?query=12345');
131+
expect(validated.site.domain).to.equal('domain.com');
132+
expect(validated.site.content.data).to.deep.equal([{segment: [{id: 'test'}], name: 'bar'}]);
133+
expect(validated.user.data).to.be.undefined;
134+
expect(validated.device).to.deep.to.equal({w: 1, h: 1});
135+
expect(validated.site.keywords).to.be.undefined;
136+
});
124137
});
125138

126139
it('should not overwrite existing data with default settings', function () {
127-
let validated;
128140
const global = {
129141
site: {
130142
ref: 'https://referer.com'
131143
}
132144
};
133145

134-
({global: validated} = processFpd({global}));
135-
expect(validated.site.ref).to.equal('https://referer.com');
146+
return processFpd({global}).then(({global: validated}) => {
147+
expect(validated.site.ref).to.equal('https://referer.com');
148+
});
136149
});
137150

138151
it('should allow overwrite default data with setConfig', function () {
139-
let validated;
140152
const global = {
141153
site: {
142154
ref: 'https://referer.com'
143155
}
144156
};
145157

146-
({global: validated} = processFpd({global}));
147-
expect(validated.site.ref).to.equal('https://referer.com');
158+
return processFpd({global}).then(({global: validated}) => {
159+
expect(validated.site.ref).to.equal('https://referer.com');
160+
});
148161
});
149162

150163
it('should filter all data', function () {
151-
let validated;
152164
let global = {
153165
imp: [],
154166
site: {
@@ -179,15 +191,13 @@ describe('the first party data module', function () {
179191
adServerCurrency: 'USD'
180192
}
181193
};
182-
183194
config.setConfig({'firstPartyData': {skipEnrichments: true}});
184-
185-
({global: validated} = processFpd({global}));
186-
expect(validated).to.deep.equal({});
195+
return processFpd({global}).then(({global: validated}) => {
196+
expect(validated).to.deep.equal({});
197+
});
187198
});
188199

189200
it('should add enrichments but not alter any arbitrary ortb2 data', function () {
190-
let validated;
191201
let global = {
192202
site: {
193203
ext: {
@@ -205,12 +215,12 @@ describe('the first party data module', function () {
205215
},
206216
cur: ['USD']
207217
};
208-
209-
({global: validated} = processFpd({global}));
210-
expect(validated.site.ref).to.equal(getRefererInfo().referer);
211-
expect(validated.site.ext.data).to.deep.equal({inventory: ['value1']});
212-
expect(validated.user.ext.data).to.deep.equal({visitor: ['value2']});
213-
expect(validated.cur).to.deep.equal(['USD']);
218+
return processFpd({global}).then(({global: validated}) => {
219+
expect(validated.site.ref).to.equal(getRefererInfo().referer);
220+
expect(validated.site.ext.data).to.deep.equal({inventory: ['value1']});
221+
expect(validated.user.ext.data).to.deep.equal({visitor: ['value2']});
222+
expect(validated.cur).to.deep.equal(['USD']);
223+
})
214224
});
215225

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

233-
const {bidder: validated} = processFpd({bidder});
234-
expect(validated.bidderA).to.not.be.undefined;
235-
expect(validated.bidderA.user.data).to.be.undefined;
236-
expect(validated.bidderA.user.keywords).to.equal('test');
237-
expect(validated.bidderA.site.keywords).to.equal('other');
238-
expect(validated.bidderA.site.ref).to.equal('https://domain.com');
243+
return processFpd({bidder}).then(({bidder: validated}) => {
244+
expect(validated.bidderA).to.not.be.undefined;
245+
expect(validated.bidderA.user.data).to.be.undefined;
246+
expect(validated.bidderA.user.keywords).to.equal('test');
247+
expect(validated.bidderA.site.keywords).to.equal('other');
248+
expect(validated.bidderA.site.ref).to.equal('https://domain.com');
249+
})
239250
});
240251

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

258-
const {bidder: validated} = processFpd({bidder});
259-
260-
expect(validated.bidderA).to.not.be.undefined;
261-
expect(validated.bidderA.user.data).to.deep.equal([{segment: [{id: 'data1_id'}], name: 'data1'}]);
262-
expect(validated.bidderA.user.keywords).to.equal('test');
263-
expect(validated.bidderA.site.keywords).to.equal('other');
264-
expect(validated.bidderA.site.ref).to.equal('https://domain.com');
269+
return processFpd({bidder}).then(({bidder: validated}) => {
270+
expect(validated.bidderA).to.not.be.undefined;
271+
expect(validated.bidderA.user.data).to.deep.equal([{segment: [{id: 'data1_id'}], name: 'data1'}]);
272+
expect(validated.bidderA.user.keywords).to.equal('test');
273+
expect(validated.bidderA.site.keywords).to.equal('other');
274+
expect(validated.bidderA.site.ref).to.equal('https://domain.com');
275+
});
265276
});
266277

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

271281
let global = {
@@ -281,15 +291,15 @@ describe('the first party data module', function () {
281291
}
282292
};
283293

284-
({global: validated} = processFpd({global}));
285-
expect(validated.device).to.be.undefined;
286-
expect(validated.site.ref).to.be.undefined;
287-
expect(validated.site.page).to.be.undefined;
288-
expect(validated.site.domain).to.be.undefined;
294+
return processFpd({global}).then(({global: validated}) => {
295+
expect(validated.device).to.be.undefined;
296+
expect(validated.site.ref).to.be.undefined;
297+
expect(validated.site.page).to.be.undefined;
298+
expect(validated.site.domain).to.be.undefined;
299+
});
289300
});
290301

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

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

307-
({global: validated} = processFpd({global}));
308-
expect(validated.user.data).to.deep.equal([{segment: [{id: 'nonfiltered'}]}]);
317+
return processFpd({global}).then(({global: validated}) => {
318+
expect(validated.user.data).to.deep.equal([{segment: [{id: 'nonfiltered'}]}]);
319+
});
309320
});
310321
});
311322
});

0 commit comments

Comments
 (0)