@mixpeek/prebid 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +153 -0
- package/ENDPOINTS.md +308 -0
- package/LICENSE +68 -0
- package/QUICKSTART.md +234 -0
- package/README.md +439 -0
- package/TESTING.md +341 -0
- package/dist/mixpeekContextAdapter.js +3 -0
- package/dist/mixpeekContextAdapter.js.LICENSE.txt +1 -0
- package/dist/mixpeekContextAdapter.js.map +1 -0
- package/docs/MIGRATION_V2.md +519 -0
- package/docs/api-reference.md +455 -0
- package/docs/health-check.md +348 -0
- package/docs/integration-guide.md +577 -0
- package/examples/publisher-demo/README.md +65 -0
- package/examples/publisher-demo/index.html +331 -0
- package/examples/publisher-demo/package.json +11 -0
- package/package.json +82 -0
- package/src/api/mixpeekClient.js +303 -0
- package/src/cache/cacheManager.js +245 -0
- package/src/config/constants.js +125 -0
- package/src/extractors/imageExtractor.js +142 -0
- package/src/extractors/pageExtractor.js +196 -0
- package/src/extractors/videoExtractor.js +228 -0
- package/src/modules/mixpeekContextAdapter.js +833 -0
- package/src/modules/mixpeekRtdProvider.js +305 -0
- package/src/prebid/prebidIntegration.js +117 -0
- package/src/utils/helpers.js +261 -0
- package/src/utils/iabMapping.js +367 -0
- package/src/utils/logger.js +64 -0
- package/src/utils/previousAdTracker.js +95 -0
|
@@ -0,0 +1,833 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Mixpeek Context Adapter for Prebid.js
|
|
3
|
+
* @module modules/mixpeekContextAdapter
|
|
4
|
+
* @version 1.0.0
|
|
5
|
+
*
|
|
6
|
+
* This module enriches Prebid.js bid requests with contextual data from Mixpeek's
|
|
7
|
+
* multimodal AI engine. It extracts page, video, or image content and classifies
|
|
8
|
+
* it using Mixpeek's API to provide IAB taxonomy, brand safety scores, and other
|
|
9
|
+
* contextual signals for privacy-safe ad targeting.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import {
|
|
13
|
+
MIXPEEK_MODULE_NAME,
|
|
14
|
+
CONTENT_MODES,
|
|
15
|
+
TARGETING_KEYS,
|
|
16
|
+
EVENTS,
|
|
17
|
+
DEFAULT_CACHE_TTL,
|
|
18
|
+
DEFAULT_TIMEOUT,
|
|
19
|
+
DEFAULT_RETRY_ATTEMPTS,
|
|
20
|
+
PERFORMANCE,
|
|
21
|
+
FEATURE_EXTRACTORS
|
|
22
|
+
} from '../config/constants.js'
|
|
23
|
+
import { validateConfig, deepMerge, hashString, isBrowser } from '../utils/helpers.js'
|
|
24
|
+
import logger from '../utils/logger.js'
|
|
25
|
+
import cacheManager from '../cache/cacheManager.js'
|
|
26
|
+
import MixpeekClient from '../api/mixpeekClient.js'
|
|
27
|
+
import { extractPageContent, extractArticleContent, isArticlePage } from '../extractors/pageExtractor.js'
|
|
28
|
+
import { extractVideoContent, hasVideo, extractVideoPlayerInfo } from '../extractors/videoExtractor.js'
|
|
29
|
+
import { extractImages, extractOGImage, hasImages } from '../extractors/imageExtractor.js'
|
|
30
|
+
import { getIABFromTaxonomy, mapCategoriesToIAB, IAB_TAXONOMY_VERSION } from '../utils/iabMapping.js'
|
|
31
|
+
import previousAdTracker from '../utils/previousAdTracker.js'
|
|
32
|
+
|
|
33
|
+
class MixpeekContextAdapter {
|
|
34
|
+
constructor() {
|
|
35
|
+
this.config = {}
|
|
36
|
+
this.client = null
|
|
37
|
+
this.initialized = false
|
|
38
|
+
this.processing = false
|
|
39
|
+
this.contextData = null
|
|
40
|
+
this.events = {}
|
|
41
|
+
this.healthCheckPerformed = false
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Initialize the adapter with configuration
|
|
46
|
+
* @param {object} config - Configuration object
|
|
47
|
+
* @returns {Promise<boolean>} Success
|
|
48
|
+
*/
|
|
49
|
+
async init(config) {
|
|
50
|
+
logger.info('Initializing Mixpeek Context Adapter')
|
|
51
|
+
logger.group('Configuration')
|
|
52
|
+
|
|
53
|
+
// Validate configuration
|
|
54
|
+
const validation = validateConfig(config)
|
|
55
|
+
if (!validation.valid) {
|
|
56
|
+
logger.error('Invalid configuration:', validation.errors)
|
|
57
|
+
logger.groupEnd()
|
|
58
|
+
return false
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Set default configuration
|
|
62
|
+
this.config = deepMerge({
|
|
63
|
+
endpoint: 'https://api.mixpeek.com',
|
|
64
|
+
timeout: DEFAULT_TIMEOUT,
|
|
65
|
+
cacheTTL: DEFAULT_CACHE_TTL,
|
|
66
|
+
retryAttempts: DEFAULT_RETRY_ATTEMPTS,
|
|
67
|
+
mode: CONTENT_MODES.AUTO,
|
|
68
|
+
enableCache: true,
|
|
69
|
+
debug: false,
|
|
70
|
+
healthCheck: 'lazy', // 'eager', 'lazy', or false
|
|
71
|
+
featureExtractors: [FEATURE_EXTRACTORS.TAXONOMY],
|
|
72
|
+
batchSize: 1
|
|
73
|
+
}, config)
|
|
74
|
+
|
|
75
|
+
// Set debug mode
|
|
76
|
+
logger.setDebug(this.config.debug)
|
|
77
|
+
|
|
78
|
+
// Initialize API client
|
|
79
|
+
this.client = new MixpeekClient({
|
|
80
|
+
apiKey: this.config.apiKey,
|
|
81
|
+
endpoint: this.config.endpoint,
|
|
82
|
+
namespace: this.config.namespace,
|
|
83
|
+
timeout: this.config.timeout,
|
|
84
|
+
retryAttempts: this.config.retryAttempts
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
// Configure cache
|
|
88
|
+
if (this.config.enableCache) {
|
|
89
|
+
cacheManager.setTTL(this.config.cacheTTL)
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
logger.table({
|
|
93
|
+
'API Endpoint': this.config.endpoint,
|
|
94
|
+
'Collection ID': this.config.collectionId,
|
|
95
|
+
'Namespace': this.config.namespace || 'default',
|
|
96
|
+
'Mode': this.config.mode,
|
|
97
|
+
'Timeout': `${this.config.timeout}ms`,
|
|
98
|
+
'Cache TTL': `${this.config.cacheTTL}s`,
|
|
99
|
+
'Feature Extractors': this.config.featureExtractors.join(', '),
|
|
100
|
+
'Health Check': this.config.healthCheck
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
logger.groupEnd()
|
|
104
|
+
|
|
105
|
+
// Perform health check if configured
|
|
106
|
+
if (this.config.healthCheck === 'eager') {
|
|
107
|
+
logger.info('Performing health check...')
|
|
108
|
+
const healthResult = await this._performHealthCheck()
|
|
109
|
+
|
|
110
|
+
if (!healthResult.healthy) {
|
|
111
|
+
logger.warn('Health check failed, but continuing initialization')
|
|
112
|
+
logger.warn('API may be unavailable:', healthResult.error)
|
|
113
|
+
} else {
|
|
114
|
+
logger.info('Health check passed:', healthResult.message)
|
|
115
|
+
}
|
|
116
|
+
} else if (this.config.healthCheck === 'lazy') {
|
|
117
|
+
logger.info('Health check will be performed on first request')
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
this.initialized = true
|
|
121
|
+
logger.info('Mixpeek Context Adapter initialized successfully')
|
|
122
|
+
return true
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Perform health check (internal)
|
|
127
|
+
* @private
|
|
128
|
+
* @returns {Promise<object>} Health check result
|
|
129
|
+
*/
|
|
130
|
+
async _performHealthCheck() {
|
|
131
|
+
try {
|
|
132
|
+
const startTime = performance.now()
|
|
133
|
+
const health = await this.client.healthCheck()
|
|
134
|
+
const duration = performance.now() - startTime
|
|
135
|
+
|
|
136
|
+
return {
|
|
137
|
+
healthy: true,
|
|
138
|
+
status: health.status || 'ok',
|
|
139
|
+
version: health.version,
|
|
140
|
+
latency: Math.round(duration),
|
|
141
|
+
message: `API responding in ${Math.round(duration)}ms`
|
|
142
|
+
}
|
|
143
|
+
} catch (error) {
|
|
144
|
+
return {
|
|
145
|
+
healthy: false,
|
|
146
|
+
error: error.message,
|
|
147
|
+
message: 'API health check failed'
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Enrich ad units with contextual data
|
|
154
|
+
* @param {array} adUnits - Prebid ad units
|
|
155
|
+
* @returns {Promise<array>} Enriched ad units
|
|
156
|
+
*/
|
|
157
|
+
async enrichAdUnits(adUnits) {
|
|
158
|
+
if (!this.initialized) {
|
|
159
|
+
logger.warn('Adapter not initialized')
|
|
160
|
+
return adUnits
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
if (this.processing) {
|
|
164
|
+
logger.warn('Already processing context')
|
|
165
|
+
return adUnits
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
this.processing = true
|
|
169
|
+
const startTime = performance.now()
|
|
170
|
+
|
|
171
|
+
try {
|
|
172
|
+
// Get contextual data
|
|
173
|
+
const context = await this.getContext()
|
|
174
|
+
|
|
175
|
+
if (!context) {
|
|
176
|
+
logger.warn('No context data available')
|
|
177
|
+
return adUnits
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// Store context
|
|
181
|
+
this.contextData = context
|
|
182
|
+
|
|
183
|
+
// Inject targeting keys into ad units
|
|
184
|
+
const enrichedAdUnits = this._injectTargetingKeys(adUnits, context)
|
|
185
|
+
|
|
186
|
+
const duration = performance.now() - startTime
|
|
187
|
+
logger.info(`Context enrichment completed in ${duration.toFixed(2)}ms`)
|
|
188
|
+
|
|
189
|
+
// Check performance threshold
|
|
190
|
+
if (duration > PERFORMANCE.WARN_LATENCY) {
|
|
191
|
+
logger.warn(`Enrichment took ${duration.toFixed(2)}ms (threshold: ${PERFORMANCE.WARN_LATENCY}ms)`)
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// Emit success event
|
|
195
|
+
this._emitEvent(EVENTS.CONTEXT_READY, context)
|
|
196
|
+
|
|
197
|
+
return enrichedAdUnits
|
|
198
|
+
} catch (error) {
|
|
199
|
+
logger.error('Error enriching ad units:', error)
|
|
200
|
+
|
|
201
|
+
// Emit error event
|
|
202
|
+
this._emitEvent(EVENTS.CONTEXT_ERROR, error)
|
|
203
|
+
|
|
204
|
+
// Return original ad units (graceful degradation)
|
|
205
|
+
return adUnits
|
|
206
|
+
} finally {
|
|
207
|
+
this.processing = false
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* Get contextual data from cache or API
|
|
213
|
+
* @returns {Promise<object>} Context data
|
|
214
|
+
*/
|
|
215
|
+
async getContext() {
|
|
216
|
+
logger.time('getContext')
|
|
217
|
+
|
|
218
|
+
try {
|
|
219
|
+
// Perform lazy health check on first request
|
|
220
|
+
if (this.config.healthCheck === 'lazy' && !this.healthCheckPerformed) {
|
|
221
|
+
logger.info('Performing lazy health check...')
|
|
222
|
+
const healthResult = await this._performHealthCheck()
|
|
223
|
+
this.healthCheckPerformed = true
|
|
224
|
+
|
|
225
|
+
if (!healthResult.healthy) {
|
|
226
|
+
logger.warn('Health check failed:', healthResult.error)
|
|
227
|
+
logger.warn('Proceeding anyway, errors will be handled gracefully')
|
|
228
|
+
} else {
|
|
229
|
+
logger.info('Health check passed:', healthResult.message)
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// Detect content mode
|
|
234
|
+
const mode = this._detectContentMode()
|
|
235
|
+
logger.info('Content mode:', mode)
|
|
236
|
+
|
|
237
|
+
// Extract content based on mode
|
|
238
|
+
const content = await this._extractContent(mode)
|
|
239
|
+
|
|
240
|
+
if (!content) {
|
|
241
|
+
logger.warn('No content extracted')
|
|
242
|
+
logger.timeEnd('getContext')
|
|
243
|
+
return null
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// Generate cache key
|
|
247
|
+
const cacheKey = this._generateCacheKey(content, mode)
|
|
248
|
+
|
|
249
|
+
// Check cache
|
|
250
|
+
if (this.config.enableCache) {
|
|
251
|
+
const cached = cacheManager.get(cacheKey)
|
|
252
|
+
if (cached) {
|
|
253
|
+
logger.info('Using cached context')
|
|
254
|
+
logger.timeEnd('getContext')
|
|
255
|
+
this._emitEvent(EVENTS.CONTEXT_CACHED, cached)
|
|
256
|
+
return cached
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// Process content with Mixpeek API
|
|
261
|
+
logger.info('Processing content with Mixpeek API')
|
|
262
|
+
this._emitEvent(EVENTS.API_REQUEST, { content, mode })
|
|
263
|
+
|
|
264
|
+
const document = await this.client.processContent(
|
|
265
|
+
this.config.collectionId,
|
|
266
|
+
content,
|
|
267
|
+
this.config.featureExtractors
|
|
268
|
+
)
|
|
269
|
+
|
|
270
|
+
this._emitEvent(EVENTS.API_RESPONSE, document)
|
|
271
|
+
|
|
272
|
+
// Parse context from document
|
|
273
|
+
const context = this._parseContext(document, content, mode)
|
|
274
|
+
|
|
275
|
+
// Cache the result
|
|
276
|
+
if (this.config.enableCache) {
|
|
277
|
+
cacheManager.set(cacheKey, context)
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
logger.timeEnd('getContext')
|
|
281
|
+
return context
|
|
282
|
+
} catch (error) {
|
|
283
|
+
logger.error('Error getting context:', error)
|
|
284
|
+
logger.timeEnd('getContext')
|
|
285
|
+
throw error
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/**
|
|
290
|
+
* Detect content mode (page, video, image, or auto)
|
|
291
|
+
* @private
|
|
292
|
+
* @returns {string} Content mode
|
|
293
|
+
*/
|
|
294
|
+
_detectContentMode() {
|
|
295
|
+
if (this.config.mode !== CONTENT_MODES.AUTO) {
|
|
296
|
+
return this.config.mode
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
// Auto-detect based on page content
|
|
300
|
+
if (hasVideo()) {
|
|
301
|
+
return CONTENT_MODES.VIDEO
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
if (isArticlePage() || hasImages()) {
|
|
305
|
+
return CONTENT_MODES.PAGE
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
return CONTENT_MODES.PAGE // Default to page
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/**
|
|
312
|
+
* Extract content based on mode
|
|
313
|
+
* @private
|
|
314
|
+
* @param {string} mode - Content mode
|
|
315
|
+
* @returns {Promise<object>} Extracted content
|
|
316
|
+
*/
|
|
317
|
+
async _extractContent(mode) {
|
|
318
|
+
if (!isBrowser()) {
|
|
319
|
+
logger.warn('Not in browser environment')
|
|
320
|
+
return null
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
logger.info(`Extracting ${mode} content`)
|
|
324
|
+
|
|
325
|
+
switch (mode) {
|
|
326
|
+
case CONTENT_MODES.VIDEO:
|
|
327
|
+
return this._extractVideoContent()
|
|
328
|
+
|
|
329
|
+
case CONTENT_MODES.IMAGE:
|
|
330
|
+
return this._extractImageContent()
|
|
331
|
+
|
|
332
|
+
case CONTENT_MODES.PAGE:
|
|
333
|
+
default:
|
|
334
|
+
return this._extractPageContent()
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
/**
|
|
339
|
+
* Extract page content
|
|
340
|
+
* @private
|
|
341
|
+
* @returns {object} Page content
|
|
342
|
+
*/
|
|
343
|
+
_extractPageContent() {
|
|
344
|
+
const pageContent = extractPageContent()
|
|
345
|
+
|
|
346
|
+
if (!pageContent) return null
|
|
347
|
+
|
|
348
|
+
// If it's an article, extract article-specific content
|
|
349
|
+
if (isArticlePage()) {
|
|
350
|
+
const articleContent = extractArticleContent()
|
|
351
|
+
if (articleContent) {
|
|
352
|
+
return {
|
|
353
|
+
...pageContent,
|
|
354
|
+
article: articleContent
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
// Extract featured image
|
|
360
|
+
const ogImage = extractOGImage()
|
|
361
|
+
if (ogImage) {
|
|
362
|
+
pageContent.featuredImage = ogImage
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
return pageContent
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
/**
|
|
369
|
+
* Extract video content
|
|
370
|
+
* @private
|
|
371
|
+
* @returns {object} Video content
|
|
372
|
+
*/
|
|
373
|
+
_extractVideoContent() {
|
|
374
|
+
const videoSelector = this.config.videoSelector || 'video'
|
|
375
|
+
const videoContent = extractVideoContent(videoSelector)
|
|
376
|
+
|
|
377
|
+
if (!videoContent) {
|
|
378
|
+
// Check for embedded video players
|
|
379
|
+
const playerInfo = extractVideoPlayerInfo()
|
|
380
|
+
if (playerInfo) {
|
|
381
|
+
return {
|
|
382
|
+
...playerInfo,
|
|
383
|
+
type: 'embedded'
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
return null
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
return {
|
|
390
|
+
...videoContent,
|
|
391
|
+
type: 'native'
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
/**
|
|
396
|
+
* Extract image content
|
|
397
|
+
* @private
|
|
398
|
+
* @returns {object} Image content
|
|
399
|
+
*/
|
|
400
|
+
_extractImageContent() {
|
|
401
|
+
const images = extractImages(this.config.maxImages || 5)
|
|
402
|
+
|
|
403
|
+
if (images.length === 0) return null
|
|
404
|
+
|
|
405
|
+
return {
|
|
406
|
+
images,
|
|
407
|
+
primaryImage: images[0],
|
|
408
|
+
count: images.length
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
/**
|
|
413
|
+
* Generate cache key for content
|
|
414
|
+
* @private
|
|
415
|
+
* @param {object} content - Content object
|
|
416
|
+
* @param {string} mode - Content mode
|
|
417
|
+
* @returns {string} Cache key
|
|
418
|
+
*/
|
|
419
|
+
_generateCacheKey(content, mode) {
|
|
420
|
+
let keyString = mode
|
|
421
|
+
|
|
422
|
+
if (content.url) {
|
|
423
|
+
keyString += `_${content.url}`
|
|
424
|
+
} else if (content.src) {
|
|
425
|
+
keyString += `_${content.src}`
|
|
426
|
+
} else if (content.images) {
|
|
427
|
+
keyString += `_${content.images[0]?.src || ''}`
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
// Add feature extractors to key
|
|
431
|
+
keyString += `_${this.config.featureExtractors.sort().join('_')}`
|
|
432
|
+
|
|
433
|
+
return hashString(keyString)
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
/**
|
|
437
|
+
* Parse context from Mixpeek document response
|
|
438
|
+
* @private
|
|
439
|
+
* @param {object} document - Mixpeek document
|
|
440
|
+
* @param {object} content - Original content
|
|
441
|
+
* @param {string} mode - Content mode
|
|
442
|
+
* @returns {object} Parsed context
|
|
443
|
+
*/
|
|
444
|
+
_parseContext(document, content, mode) {
|
|
445
|
+
const context = {
|
|
446
|
+
documentId: document.document_id,
|
|
447
|
+
mode,
|
|
448
|
+
content: {
|
|
449
|
+
url: content.url || content.src || '',
|
|
450
|
+
title: content.title || '',
|
|
451
|
+
type: mode
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
// Extract taxonomies
|
|
456
|
+
if (document.enrichments && document.enrichments.taxonomies) {
|
|
457
|
+
const taxonomies = document.enrichments.taxonomies
|
|
458
|
+
|
|
459
|
+
if (taxonomies.length > 0) {
|
|
460
|
+
const primaryTaxonomy = taxonomies[0]
|
|
461
|
+
context.taxonomy = {
|
|
462
|
+
label: primaryTaxonomy.label,
|
|
463
|
+
nodeId: primaryTaxonomy.node_id,
|
|
464
|
+
path: primaryTaxonomy.path,
|
|
465
|
+
score: primaryTaxonomy.score,
|
|
466
|
+
all: taxonomies
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
// Extract other enrichments
|
|
472
|
+
if (document.enrichments) {
|
|
473
|
+
// Brand safety
|
|
474
|
+
if (document.enrichments.brand_safety) {
|
|
475
|
+
context.brandSafety = document.enrichments.brand_safety
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
// Keywords
|
|
479
|
+
if (document.enrichments.keywords) {
|
|
480
|
+
context.keywords = document.enrichments.keywords
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
// Sentiment
|
|
484
|
+
if (document.enrichments.sentiment) {
|
|
485
|
+
context.sentiment = document.enrichments.sentiment
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
// Embeddings
|
|
489
|
+
if (document.enrichments.embeddings) {
|
|
490
|
+
context.embeddingId = document.enrichments.embeddings[0]?.id || null
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
return context
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
/**
|
|
498
|
+
* Inject targeting keys into ad units
|
|
499
|
+
* @private
|
|
500
|
+
* @param {array} adUnits - Ad units
|
|
501
|
+
* @param {object} context - Context data
|
|
502
|
+
* @returns {array} Enriched ad units
|
|
503
|
+
*/
|
|
504
|
+
_injectTargetingKeys(adUnits, context) {
|
|
505
|
+
const targetingKeys = this._buildTargetingKeys(context)
|
|
506
|
+
|
|
507
|
+
logger.info('Targeting keys:', targetingKeys)
|
|
508
|
+
|
|
509
|
+
return adUnits.map(adUnit => {
|
|
510
|
+
// Add to first-party data (ortb2Imp)
|
|
511
|
+
if (!adUnit.ortb2Imp) {
|
|
512
|
+
adUnit.ortb2Imp = {}
|
|
513
|
+
}
|
|
514
|
+
if (!adUnit.ortb2Imp.ext) {
|
|
515
|
+
adUnit.ortb2Imp.ext = {}
|
|
516
|
+
}
|
|
517
|
+
if (!adUnit.ortb2Imp.ext.data) {
|
|
518
|
+
adUnit.ortb2Imp.ext.data = {}
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
// Merge targeting keys
|
|
522
|
+
Object.assign(adUnit.ortb2Imp.ext.data, targetingKeys)
|
|
523
|
+
|
|
524
|
+
// Also add to legacy targeting
|
|
525
|
+
if (!adUnit.bids) {
|
|
526
|
+
adUnit.bids = []
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
adUnit.bids = adUnit.bids.map(bid => {
|
|
530
|
+
if (!bid.params) {
|
|
531
|
+
bid.params = {}
|
|
532
|
+
}
|
|
533
|
+
if (!bid.params.keywords) {
|
|
534
|
+
bid.params.keywords = {}
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
Object.assign(bid.params.keywords, targetingKeys)
|
|
538
|
+
return bid
|
|
539
|
+
})
|
|
540
|
+
|
|
541
|
+
return adUnit
|
|
542
|
+
})
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
/**
|
|
546
|
+
* Build targeting keys from context
|
|
547
|
+
* @private
|
|
548
|
+
* @param {object} context - Context data
|
|
549
|
+
* @returns {object} Targeting keys
|
|
550
|
+
*/
|
|
551
|
+
_buildTargetingKeys(context) {
|
|
552
|
+
const keys = {}
|
|
553
|
+
|
|
554
|
+
// Taxonomy
|
|
555
|
+
if (context.taxonomy) {
|
|
556
|
+
keys[TARGETING_KEYS.CATEGORY] = context.taxonomy.label
|
|
557
|
+
keys[TARGETING_KEYS.NODE] = context.taxonomy.nodeId
|
|
558
|
+
keys[TARGETING_KEYS.PATH] = Array.isArray(context.taxonomy.path)
|
|
559
|
+
? context.taxonomy.path.join('/')
|
|
560
|
+
: context.taxonomy.path
|
|
561
|
+
keys[TARGETING_KEYS.SCORE] = context.taxonomy.score.toFixed(2)
|
|
562
|
+
|
|
563
|
+
// Extract IAB taxonomy code if available
|
|
564
|
+
const iabMatch = context.taxonomy.label.match(/IAB\d+-\d+/)
|
|
565
|
+
if (iabMatch) {
|
|
566
|
+
keys[TARGETING_KEYS.TAXONOMY] = iabMatch[0]
|
|
567
|
+
}
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
// Brand safety
|
|
571
|
+
if (context.brandSafety) {
|
|
572
|
+
keys[TARGETING_KEYS.SAFETY] = typeof context.brandSafety === 'number'
|
|
573
|
+
? context.brandSafety.toFixed(2)
|
|
574
|
+
: context.brandSafety.score?.toFixed(2) || '0'
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
// Keywords
|
|
578
|
+
if (context.keywords) {
|
|
579
|
+
keys[TARGETING_KEYS.KEYWORDS] = Array.isArray(context.keywords)
|
|
580
|
+
? context.keywords.slice(0, 10).join(',')
|
|
581
|
+
: context.keywords
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
// Sentiment
|
|
585
|
+
if (context.sentiment) {
|
|
586
|
+
keys[TARGETING_KEYS.SENTIMENT] = typeof context.sentiment === 'string'
|
|
587
|
+
? context.sentiment
|
|
588
|
+
: context.sentiment.label || 'neutral'
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
// Embedding ID
|
|
592
|
+
if (context.embeddingId) {
|
|
593
|
+
keys[TARGETING_KEYS.EMBED] = context.embeddingId
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
// Previous ad targeting (non-PII, adjacency awareness)
|
|
597
|
+
const lastAd = previousAdTracker.getLast()
|
|
598
|
+
if (lastAd) {
|
|
599
|
+
if (lastAd.creativeId) {
|
|
600
|
+
keys[TARGETING_KEYS.PREV_AD_CREATIVE_ID] = String(lastAd.creativeId)
|
|
601
|
+
}
|
|
602
|
+
if (lastAd.bidder) {
|
|
603
|
+
keys[TARGETING_KEYS.PREV_AD_BIDDER] = String(lastAd.bidder)
|
|
604
|
+
}
|
|
605
|
+
if (lastAd.adUnitCode) {
|
|
606
|
+
keys[TARGETING_KEYS.PREV_AD_ADUNIT] = String(lastAd.adUnitCode)
|
|
607
|
+
}
|
|
608
|
+
if (Array.isArray(lastAd.categories) && lastAd.categories.length > 0) {
|
|
609
|
+
keys[TARGETING_KEYS.PREV_AD_CAT] = lastAd.categories.slice(0, 5).join(',')
|
|
610
|
+
}
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
return keys
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
/**
|
|
617
|
+
* Format context data for OpenRTB 2.6 site.content
|
|
618
|
+
* @param {object} context - Context data
|
|
619
|
+
* @returns {object|null} ortb2 formatted site.content object
|
|
620
|
+
*/
|
|
621
|
+
formatForOrtb2SiteContent(context) {
|
|
622
|
+
if (!context) return null
|
|
623
|
+
|
|
624
|
+
const contentData = {}
|
|
625
|
+
|
|
626
|
+
// IAB Content Categories
|
|
627
|
+
if (context.taxonomy) {
|
|
628
|
+
const iabCode = getIABFromTaxonomy(context.taxonomy)
|
|
629
|
+
if (iabCode) {
|
|
630
|
+
contentData.cat = [iabCode]
|
|
631
|
+
contentData.cattax = IAB_TAXONOMY_VERSION
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
// Genre (human-readable category)
|
|
635
|
+
if (context.taxonomy.label) {
|
|
636
|
+
contentData.genre = context.taxonomy.label
|
|
637
|
+
}
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
// Keywords
|
|
641
|
+
if (context.keywords) {
|
|
642
|
+
contentData.keywords = Array.isArray(context.keywords)
|
|
643
|
+
? context.keywords.join(',')
|
|
644
|
+
: context.keywords
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
// Language detection (from page or default)
|
|
648
|
+
if (isBrowser() && document.documentElement.lang) {
|
|
649
|
+
contentData.language = document.documentElement.lang
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
// Page metadata
|
|
653
|
+
if (isBrowser()) {
|
|
654
|
+
if (document.title) {
|
|
655
|
+
contentData.title = document.title
|
|
656
|
+
}
|
|
657
|
+
if (window.location.href) {
|
|
658
|
+
contentData.url = window.location.href
|
|
659
|
+
}
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
// Content-specific metadata from context
|
|
663
|
+
if (context.content) {
|
|
664
|
+
if (context.content.url && !contentData.url) {
|
|
665
|
+
contentData.url = context.content.url
|
|
666
|
+
}
|
|
667
|
+
if (context.content.title && !contentData.title) {
|
|
668
|
+
contentData.title = context.content.title
|
|
669
|
+
}
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
// Extension data (Mixpeek-specific)
|
|
673
|
+
contentData.ext = {
|
|
674
|
+
data: {
|
|
675
|
+
mixpeek: {
|
|
676
|
+
documentId: context.documentId,
|
|
677
|
+
mode: context.mode,
|
|
678
|
+
score: context.taxonomy?.score,
|
|
679
|
+
brandSafety: context.brandSafety,
|
|
680
|
+
sentiment: context.sentiment,
|
|
681
|
+
embeddingId: context.embeddingId
|
|
682
|
+
}
|
|
683
|
+
}
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
return contentData
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
/**
|
|
690
|
+
* Format context data for ortb2Fragments (used by RTD modules)
|
|
691
|
+
* @param {object} context - Context data
|
|
692
|
+
* @returns {object|null} ortb2Fragments object
|
|
693
|
+
*/
|
|
694
|
+
formatForOrtb2Fragments(context) {
|
|
695
|
+
if (!context) return null
|
|
696
|
+
|
|
697
|
+
const contentData = this.formatForOrtb2SiteContent(context)
|
|
698
|
+
if (!contentData) return null
|
|
699
|
+
|
|
700
|
+
return {
|
|
701
|
+
global: {
|
|
702
|
+
site: {
|
|
703
|
+
content: contentData
|
|
704
|
+
}
|
|
705
|
+
}
|
|
706
|
+
}
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
/**
|
|
710
|
+
* Format context as first-party data segments (alternative format)
|
|
711
|
+
* @param {object} context - Context data
|
|
712
|
+
* @returns {Array} Array of data segments
|
|
713
|
+
*/
|
|
714
|
+
formatAsDataSegments(context) {
|
|
715
|
+
if (!context || !context.taxonomy) return []
|
|
716
|
+
|
|
717
|
+
const segments = []
|
|
718
|
+
|
|
719
|
+
// Primary taxonomy segment
|
|
720
|
+
const iabCode = getIABFromTaxonomy(context.taxonomy)
|
|
721
|
+
if (iabCode) {
|
|
722
|
+
segments.push({
|
|
723
|
+
id: iabCode,
|
|
724
|
+
name: context.taxonomy.label,
|
|
725
|
+
value: context.taxonomy.score.toString()
|
|
726
|
+
})
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
// Additional taxonomies if available
|
|
730
|
+
if (context.taxonomy.all && Array.isArray(context.taxonomy.all)) {
|
|
731
|
+
context.taxonomy.all.slice(1, 5).forEach(tax => {
|
|
732
|
+
const code = getIABFromTaxonomy(tax)
|
|
733
|
+
if (code) {
|
|
734
|
+
segments.push({
|
|
735
|
+
id: code,
|
|
736
|
+
name: tax.label,
|
|
737
|
+
value: tax.score.toString()
|
|
738
|
+
})
|
|
739
|
+
}
|
|
740
|
+
})
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
return segments
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
/**
|
|
747
|
+
* Register event listener
|
|
748
|
+
* @param {string} event - Event name
|
|
749
|
+
* @param {Function} callback - Callback function
|
|
750
|
+
*/
|
|
751
|
+
on(event, callback) {
|
|
752
|
+
if (!this.events[event]) {
|
|
753
|
+
this.events[event] = []
|
|
754
|
+
}
|
|
755
|
+
this.events[event].push(callback)
|
|
756
|
+
}
|
|
757
|
+
|
|
758
|
+
/**
|
|
759
|
+
* Emit event
|
|
760
|
+
* @private
|
|
761
|
+
* @param {string} event - Event name
|
|
762
|
+
* @param {*} data - Event data
|
|
763
|
+
*/
|
|
764
|
+
_emitEvent(event, data) {
|
|
765
|
+
if (this.events[event]) {
|
|
766
|
+
this.events[event].forEach(callback => {
|
|
767
|
+
try {
|
|
768
|
+
callback(data)
|
|
769
|
+
} catch (error) {
|
|
770
|
+
logger.error(`Error in event callback for ${event}:`, error)
|
|
771
|
+
}
|
|
772
|
+
})
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
// Also emit to window for Prebid integration
|
|
776
|
+
if (isBrowser() && window.pbjs) {
|
|
777
|
+
window.pbjs.emit(event, data)
|
|
778
|
+
}
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
/**
|
|
782
|
+
* Get current context data
|
|
783
|
+
* @returns {object|null} Context data
|
|
784
|
+
*/
|
|
785
|
+
getContextData() {
|
|
786
|
+
return this.contextData
|
|
787
|
+
}
|
|
788
|
+
|
|
789
|
+
/**
|
|
790
|
+
* Clear cache
|
|
791
|
+
*/
|
|
792
|
+
clearCache() {
|
|
793
|
+
cacheManager.clear()
|
|
794
|
+
logger.info('Cache cleared')
|
|
795
|
+
}
|
|
796
|
+
|
|
797
|
+
/**
|
|
798
|
+
* Get cache statistics
|
|
799
|
+
* @returns {object} Cache stats
|
|
800
|
+
*/
|
|
801
|
+
getCacheStats() {
|
|
802
|
+
return cacheManager.getStats()
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
/**
|
|
806
|
+
* Health check
|
|
807
|
+
* @returns {Promise<object>} Health status
|
|
808
|
+
*/
|
|
809
|
+
async healthCheck() {
|
|
810
|
+
if (!this.client) {
|
|
811
|
+
return { status: 'error', message: 'Client not initialized' }
|
|
812
|
+
}
|
|
813
|
+
|
|
814
|
+
try {
|
|
815
|
+
const health = await this.client.healthCheck()
|
|
816
|
+
return { status: 'ok', ...health }
|
|
817
|
+
} catch (error) {
|
|
818
|
+
return { status: 'error', message: error.message }
|
|
819
|
+
}
|
|
820
|
+
}
|
|
821
|
+
}
|
|
822
|
+
|
|
823
|
+
// Create singleton instance
|
|
824
|
+
const adapter = new MixpeekContextAdapter()
|
|
825
|
+
|
|
826
|
+
// Export adapter instance
|
|
827
|
+
export default adapter
|
|
828
|
+
|
|
829
|
+
// Browser global
|
|
830
|
+
if (isBrowser()) {
|
|
831
|
+
window.MixpeekContextAdapter = adapter
|
|
832
|
+
}
|
|
833
|
+
|