@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.
@@ -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
+