@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,305 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Mixpeek Real-Time Data (RTD) Provider for Prebid.js
|
|
3
|
+
* @module modules/mixpeekRtdProvider
|
|
4
|
+
*
|
|
5
|
+
* This module implements the official Prebid RTD submodule interface
|
|
6
|
+
* to enrich bid requests with Mixpeek's multimodal contextual data.
|
|
7
|
+
*
|
|
8
|
+
* References:
|
|
9
|
+
* - Prebid RTD Module Docs: https://docs.prebid.org/dev-docs/add-rtd-submodule.html
|
|
10
|
+
* - Qortex Implementation: https://github.com/prebid/Prebid.js/blob/master/modules/qortexRtdProvider.js
|
|
11
|
+
* - OpenRTB 2.6 Spec: https://www.iab.com/wp-content/uploads/2020/09/OpenRTB_2-6_FINAL.pdf
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import adapter from './mixpeekContextAdapter.js'
|
|
15
|
+
import { logInfo, logWarn, logError } from '../utils/logger.js'
|
|
16
|
+
import { isBrowser } from '../utils/helpers.js'
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Module name (used for registration)
|
|
20
|
+
*/
|
|
21
|
+
const MODULE_NAME = 'mixpeek'
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Mixpeek RTD Submodule
|
|
25
|
+
*
|
|
26
|
+
* Implements the standard Prebid RTD interface:
|
|
27
|
+
* - init(): Initialize the module with configuration
|
|
28
|
+
* - getBidRequestData(): Enrich bid requests with contextual data
|
|
29
|
+
* - getTargetingData(): Provide targeting key-values for ad server
|
|
30
|
+
*/
|
|
31
|
+
export const mixpeekSubmodule = {
|
|
32
|
+
name: MODULE_NAME,
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Initialize the Mixpeek RTD module
|
|
36
|
+
*
|
|
37
|
+
* @param {Object} config - Module configuration from realTimeData.dataProviders[]
|
|
38
|
+
* @param {Object} config.params - Mixpeek-specific parameters
|
|
39
|
+
* @param {string} config.params.apiKey - Mixpeek API key
|
|
40
|
+
* @param {string} config.params.collectionId - Mixpeek collection ID
|
|
41
|
+
* @param {string} [config.params.endpoint] - API endpoint
|
|
42
|
+
* @param {string} [config.params.namespace] - Namespace for data isolation
|
|
43
|
+
* @param {string} [config.params.mode='auto'] - Content mode (auto, page, video, image)
|
|
44
|
+
* @param {Array<string>} [config.params.featureExtractors] - Feature extractors to use
|
|
45
|
+
* @param {number} [config.params.timeout=250] - API timeout in ms
|
|
46
|
+
* @param {number} [config.params.cacheTTL=300] - Cache TTL in seconds
|
|
47
|
+
* @param {boolean} [config.params.enableCache=true] - Enable caching
|
|
48
|
+
* @param {boolean} [config.params.debug=false] - Debug mode
|
|
49
|
+
* @param {Object} userConsent - User consent data (GDPR, USP)
|
|
50
|
+
* @param {Object} [userConsent.gdpr] - GDPR consent data
|
|
51
|
+
* @param {boolean} [userConsent.gdpr.gdprApplies] - Whether GDPR applies
|
|
52
|
+
* @param {Object} [userConsent.gdpr.purposeConsents] - Purpose consents
|
|
53
|
+
* @param {string} [userConsent.usp] - USP consent string
|
|
54
|
+
* @returns {boolean} True if initialization successful
|
|
55
|
+
*/
|
|
56
|
+
init: function(config, userConsent) {
|
|
57
|
+
logInfo(`[${MODULE_NAME}] Initializing Mixpeek RTD module`)
|
|
58
|
+
|
|
59
|
+
// Validate configuration
|
|
60
|
+
if (!config || !config.params) {
|
|
61
|
+
logError(`[${MODULE_NAME}] Configuration is required`)
|
|
62
|
+
return false
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const params = config.params
|
|
66
|
+
|
|
67
|
+
// Validate required parameters
|
|
68
|
+
if (!params.apiKey) {
|
|
69
|
+
logError(`[${MODULE_NAME}] apiKey is required`)
|
|
70
|
+
return false
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
if (!params.collectionId) {
|
|
74
|
+
logError(`[${MODULE_NAME}] collectionId is required`)
|
|
75
|
+
return false
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Log consent state (for transparency and debugging)
|
|
79
|
+
if (userConsent) {
|
|
80
|
+
if (userConsent.gdpr) {
|
|
81
|
+
logInfo(`[${MODULE_NAME}] GDPR applies: ${userConsent.gdpr.gdprApplies}`)
|
|
82
|
+
if (userConsent.gdpr.gdprApplies && params.debug) {
|
|
83
|
+
logInfo(`[${MODULE_NAME}] Purpose consents:`, userConsent.gdpr.purposeConsents)
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
if (userConsent.usp) {
|
|
88
|
+
logInfo(`[${MODULE_NAME}] USP consent string: ${userConsent.usp}`)
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// Note: Contextual analysis doesn't require user consent as it only
|
|
93
|
+
// analyzes page content, not user behavior. However, we respect the
|
|
94
|
+
// consent framework and log the state for transparency.
|
|
95
|
+
|
|
96
|
+
// Initialize the adapter
|
|
97
|
+
try {
|
|
98
|
+
const success = adapter.init(params)
|
|
99
|
+
|
|
100
|
+
if (success) {
|
|
101
|
+
logInfo(`[${MODULE_NAME}] Successfully initialized`)
|
|
102
|
+
} else {
|
|
103
|
+
logError(`[${MODULE_NAME}] Initialization failed`)
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
return success
|
|
107
|
+
} catch (error) {
|
|
108
|
+
logError(`[${MODULE_NAME}] Initialization error:`, error)
|
|
109
|
+
return false
|
|
110
|
+
}
|
|
111
|
+
},
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Get real-time data and enrich bid request
|
|
115
|
+
*
|
|
116
|
+
* This is the core method that:
|
|
117
|
+
* 1. Extracts page/video content
|
|
118
|
+
* 2. Calls Mixpeek API (with caching)
|
|
119
|
+
* 3. Formats response as OpenRTB 2.6 data
|
|
120
|
+
* 4. Injects into ortb2Fragments (site-level) and ortb2Imp (impression-level)
|
|
121
|
+
* 5. Calls callback to release the auction
|
|
122
|
+
*
|
|
123
|
+
* @param {Object} reqBidsConfigObj - Bid request configuration
|
|
124
|
+
* @param {Array} reqBidsConfigObj.adUnits - Ad units for the auction
|
|
125
|
+
* @param {Object} reqBidsConfigObj.ortb2Fragments - ortb2 data fragments
|
|
126
|
+
* @param {Function} callback - Callback to call when done (REQUIRED)
|
|
127
|
+
* @param {Object} config - Module configuration
|
|
128
|
+
* @param {Object} userConsent - User consent data
|
|
129
|
+
*/
|
|
130
|
+
getBidRequestData: function(reqBidsConfigObj, callback, config, userConsent) {
|
|
131
|
+
logInfo(`[${MODULE_NAME}] getBidRequestData called`)
|
|
132
|
+
|
|
133
|
+
// Ensure we have a callback
|
|
134
|
+
if (typeof callback !== 'function') {
|
|
135
|
+
logError(`[${MODULE_NAME}] Callback is required`)
|
|
136
|
+
return
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// Check if adapter is initialized
|
|
140
|
+
if (!adapter.initialized) {
|
|
141
|
+
logWarn(`[${MODULE_NAME}] Adapter not initialized, skipping enrichment`)
|
|
142
|
+
callback()
|
|
143
|
+
return
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// Check if in browser environment
|
|
147
|
+
if (!isBrowser()) {
|
|
148
|
+
logWarn(`[${MODULE_NAME}] Not in browser environment, skipping enrichment`)
|
|
149
|
+
callback()
|
|
150
|
+
return
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// Get context from adapter (async)
|
|
154
|
+
adapter.getContext()
|
|
155
|
+
.then(context => {
|
|
156
|
+
if (!context) {
|
|
157
|
+
logWarn(`[${MODULE_NAME}] No context data available`)
|
|
158
|
+
callback()
|
|
159
|
+
return
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
logInfo(`[${MODULE_NAME}] Context retrieved successfully`)
|
|
163
|
+
|
|
164
|
+
// Format for ortb2Fragments (site-level global data)
|
|
165
|
+
const ortb2Fragments = adapter.formatForOrtb2Fragments(context)
|
|
166
|
+
|
|
167
|
+
if (ortb2Fragments) {
|
|
168
|
+
// Initialize ortb2Fragments if needed
|
|
169
|
+
if (!reqBidsConfigObj.ortb2Fragments) {
|
|
170
|
+
reqBidsConfigObj.ortb2Fragments = {}
|
|
171
|
+
}
|
|
172
|
+
if (!reqBidsConfigObj.ortb2Fragments.global) {
|
|
173
|
+
reqBidsConfigObj.ortb2Fragments.global = {}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// Merge site.content data
|
|
177
|
+
if (ortb2Fragments.global.site) {
|
|
178
|
+
if (!reqBidsConfigObj.ortb2Fragments.global.site) {
|
|
179
|
+
reqBidsConfigObj.ortb2Fragments.global.site = {}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// Merge content data (deep merge to preserve existing data)
|
|
183
|
+
Object.assign(
|
|
184
|
+
reqBidsConfigObj.ortb2Fragments.global.site,
|
|
185
|
+
ortb2Fragments.global.site
|
|
186
|
+
)
|
|
187
|
+
|
|
188
|
+
logInfo(`[${MODULE_NAME}] Injected site.content data:`,
|
|
189
|
+
reqBidsConfigObj.ortb2Fragments.global.site.content)
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// Also enrich ad units (impression-level data)
|
|
194
|
+
if (reqBidsConfigObj.adUnits && Array.isArray(reqBidsConfigObj.adUnits)) {
|
|
195
|
+
try {
|
|
196
|
+
reqBidsConfigObj.adUnits = adapter._injectTargetingKeys(
|
|
197
|
+
reqBidsConfigObj.adUnits,
|
|
198
|
+
context
|
|
199
|
+
)
|
|
200
|
+
|
|
201
|
+
logInfo(`[${MODULE_NAME}] Enriched ${reqBidsConfigObj.adUnits.length} ad units`)
|
|
202
|
+
} catch (error) {
|
|
203
|
+
logError(`[${MODULE_NAME}] Error enriching ad units:`, error)
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// Success - release auction
|
|
208
|
+
callback()
|
|
209
|
+
})
|
|
210
|
+
.catch(error => {
|
|
211
|
+
logError(`[${MODULE_NAME}] Error getting context:`, error)
|
|
212
|
+
|
|
213
|
+
// Don't block the auction on error - graceful degradation
|
|
214
|
+
callback()
|
|
215
|
+
})
|
|
216
|
+
},
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* Get targeting data for ad server
|
|
220
|
+
*
|
|
221
|
+
* This method is called after the auction to get key-value pairs
|
|
222
|
+
* that should be sent to the ad server (e.g., GAM, AppNexus).
|
|
223
|
+
*
|
|
224
|
+
* @param {Array<string>} adUnitsCodes - Ad unit codes to get targeting for
|
|
225
|
+
* @param {Object} config - Module configuration
|
|
226
|
+
* @returns {Object} Targeting data object keyed by ad unit code
|
|
227
|
+
*/
|
|
228
|
+
getTargetingData: function(adUnitsCodes, config) {
|
|
229
|
+
logInfo(`[${MODULE_NAME}] getTargetingData called for ${adUnitsCodes?.length || 0} ad units`)
|
|
230
|
+
|
|
231
|
+
// Get current context data
|
|
232
|
+
const context = adapter.getContextData()
|
|
233
|
+
|
|
234
|
+
if (!context) {
|
|
235
|
+
logWarn(`[${MODULE_NAME}] No context data available for targeting`)
|
|
236
|
+
return {}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// Build targeting key-values
|
|
240
|
+
const targetingKeys = adapter._buildTargetingKeys(context)
|
|
241
|
+
|
|
242
|
+
// Return same targeting for all ad units (site-level context)
|
|
243
|
+
// Format: { adUnitCode1: { key: value }, adUnitCode2: { key: value } }
|
|
244
|
+
const targetingData = {}
|
|
245
|
+
|
|
246
|
+
if (Array.isArray(adUnitsCodes)) {
|
|
247
|
+
adUnitsCodes.forEach(code => {
|
|
248
|
+
targetingData[code] = targetingKeys
|
|
249
|
+
})
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
logInfo(`[${MODULE_NAME}] Returning targeting data for ${Object.keys(targetingData).length} ad units`)
|
|
253
|
+
|
|
254
|
+
return targetingData
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* Register submodule with Prebid
|
|
260
|
+
*
|
|
261
|
+
* This is called when the module is loaded. It registers the mixpeekSubmodule
|
|
262
|
+
* with Prebid's Real-Time Data module system.
|
|
263
|
+
*
|
|
264
|
+
* Note: This requires Prebid.js to have the realTimeData module included.
|
|
265
|
+
*/
|
|
266
|
+
function registerSubmodule() {
|
|
267
|
+
if (typeof window !== 'undefined' && window.pbjs) {
|
|
268
|
+
// Check if RTD module is loaded
|
|
269
|
+
if (!window.pbjs.registerRtdSubmodule) {
|
|
270
|
+
logError(`[${MODULE_NAME}] Prebid RTD module not loaded. Please include realTimeData module in your Prebid build.`)
|
|
271
|
+
return false
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
try {
|
|
275
|
+
window.pbjs.registerRtdSubmodule(mixpeekSubmodule)
|
|
276
|
+
logInfo(`[${MODULE_NAME}] RTD submodule registered successfully`)
|
|
277
|
+
return true
|
|
278
|
+
} catch (error) {
|
|
279
|
+
logError(`[${MODULE_NAME}] Failed to register RTD submodule:`, error)
|
|
280
|
+
return false
|
|
281
|
+
}
|
|
282
|
+
} else {
|
|
283
|
+
// Not in browser or Prebid not loaded yet
|
|
284
|
+
// This is normal during build/test
|
|
285
|
+
return false
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
// Auto-register when loaded in browser
|
|
290
|
+
if (isBrowser()) {
|
|
291
|
+
// Use Prebid's queue to ensure Prebid is loaded
|
|
292
|
+
window.pbjs = window.pbjs || {}
|
|
293
|
+
window.pbjs.que = window.pbjs.que || []
|
|
294
|
+
window.pbjs.que.push(function() {
|
|
295
|
+
registerSubmodule()
|
|
296
|
+
})
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
// Export for testing and direct usage
|
|
300
|
+
export default {
|
|
301
|
+
name: MODULE_NAME,
|
|
302
|
+
submodule: mixpeekSubmodule,
|
|
303
|
+
registerSubmodule
|
|
304
|
+
}
|
|
305
|
+
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Prebid.js Integration Module
|
|
3
|
+
* @module prebid/prebidIntegration
|
|
4
|
+
*
|
|
5
|
+
* This module registers the Mixpeek Context Adapter with Prebid.js and
|
|
6
|
+
* hooks into the bidding lifecycle to enrich requests with contextual data.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import adapter from '../modules/mixpeekContextAdapter.js'
|
|
10
|
+
import logger from '../utils/logger.js'
|
|
11
|
+
import previousAdTracker from '../utils/previousAdTracker.js'
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Register Mixpeek adapter with Prebid.js
|
|
15
|
+
*/
|
|
16
|
+
export function registerWithPrebid() {
|
|
17
|
+
if (typeof window === 'undefined' || !window.pbjs) {
|
|
18
|
+
logger.error('Prebid.js not found. Make sure Prebid.js is loaded before the Mixpeek adapter.')
|
|
19
|
+
return false
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const pbjs = window.pbjs
|
|
23
|
+
|
|
24
|
+
logger.info('Registering Mixpeek Context Adapter with Prebid.js')
|
|
25
|
+
|
|
26
|
+
// Hook into Prebid configuration
|
|
27
|
+
pbjs.que = pbjs.que || []
|
|
28
|
+
pbjs.que.push(function() {
|
|
29
|
+
// Listen for setConfig events
|
|
30
|
+
pbjs.onEvent('setConfig', function(config) {
|
|
31
|
+
if (config.mixpeek) {
|
|
32
|
+
logger.info('Mixpeek configuration detected')
|
|
33
|
+
adapter.init(config.mixpeek)
|
|
34
|
+
}
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
// Hook into beforeRequestBids
|
|
38
|
+
pbjs.onEvent('beforeRequestBids', async function(bidRequest) {
|
|
39
|
+
logger.info('beforeRequestBids triggered')
|
|
40
|
+
|
|
41
|
+
if (!adapter.initialized) {
|
|
42
|
+
logger.warn('Adapter not initialized, skipping enrichment')
|
|
43
|
+
return
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
try {
|
|
47
|
+
// Enrich ad units
|
|
48
|
+
const enrichedAdUnits = await adapter.enrichAdUnits(bidRequest.adUnits || [])
|
|
49
|
+
|
|
50
|
+
// Update bid request
|
|
51
|
+
if (bidRequest.adUnits) {
|
|
52
|
+
bidRequest.adUnits = enrichedAdUnits
|
|
53
|
+
}
|
|
54
|
+
} catch (error) {
|
|
55
|
+
logger.error('Error in beforeRequestBids:', error)
|
|
56
|
+
// Don't block the auction
|
|
57
|
+
}
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
// Hook into bidResponse to add analytics
|
|
61
|
+
pbjs.onEvent('bidResponse', function(bidResponse) {
|
|
62
|
+
// Record previous ad for adjacency/frequency awareness
|
|
63
|
+
try {
|
|
64
|
+
previousAdTracker.record(bidResponse)
|
|
65
|
+
} catch (e) {
|
|
66
|
+
// non-blocking
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const context = adapter.getContextData()
|
|
70
|
+
if (context) {
|
|
71
|
+
// Add context data to bid response for analytics
|
|
72
|
+
bidResponse.mixpeekContext = {
|
|
73
|
+
taxonomy: context.taxonomy?.label,
|
|
74
|
+
score: context.taxonomy?.score,
|
|
75
|
+
brandSafety: context.brandSafety
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
logger.info('Mixpeek Context Adapter registered with Prebid.js')
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
return true
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Initialize Mixpeek with Prebid config
|
|
88
|
+
* @param {object} config - Configuration object
|
|
89
|
+
*/
|
|
90
|
+
export function initialize(config) {
|
|
91
|
+
if (!config) {
|
|
92
|
+
logger.error('Configuration is required')
|
|
93
|
+
return false
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// Initialize adapter
|
|
97
|
+
const success = adapter.init(config)
|
|
98
|
+
|
|
99
|
+
if (success) {
|
|
100
|
+
// Register with Prebid
|
|
101
|
+
registerWithPrebid()
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
return success
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// Auto-register if Prebid is already loaded
|
|
108
|
+
if (typeof window !== 'undefined' && window.pbjs) {
|
|
109
|
+
registerWithPrebid()
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export default {
|
|
113
|
+
initialize,
|
|
114
|
+
registerWithPrebid,
|
|
115
|
+
adapter
|
|
116
|
+
}
|
|
117
|
+
|
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Mixpeek Context Adapter - Helper Utilities
|
|
3
|
+
* @module utils/helpers
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { ERROR_CODES, PERFORMANCE } from '../config/constants.js'
|
|
7
|
+
import logger from './logger.js'
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Generate a unique identifier
|
|
11
|
+
* @returns {string} UUID v4
|
|
12
|
+
*/
|
|
13
|
+
export function generateUUID() {
|
|
14
|
+
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function(c) {
|
|
15
|
+
const r = Math.random() * 16 | 0
|
|
16
|
+
const v = c === 'x' ? r : (r & 0x3 | 0x8)
|
|
17
|
+
return v.toString(16)
|
|
18
|
+
})
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Generate a hash from a string
|
|
23
|
+
* @param {string} str - String to hash
|
|
24
|
+
* @returns {string} Hash
|
|
25
|
+
*/
|
|
26
|
+
export function hashString(str) {
|
|
27
|
+
let hash = 0
|
|
28
|
+
if (str.length === 0) return hash.toString()
|
|
29
|
+
for (let i = 0; i < str.length; i++) {
|
|
30
|
+
const char = str.charCodeAt(i)
|
|
31
|
+
hash = ((hash << 5) - hash) + char
|
|
32
|
+
hash = hash & hash // Convert to 32bit integer
|
|
33
|
+
}
|
|
34
|
+
return Math.abs(hash).toString(36)
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Check if a value is a valid object
|
|
39
|
+
* @param {*} value - Value to check
|
|
40
|
+
* @returns {boolean}
|
|
41
|
+
*/
|
|
42
|
+
export function isObject(value) {
|
|
43
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value)
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Deep merge two objects
|
|
48
|
+
* @param {object} target - Target object
|
|
49
|
+
* @param {object} source - Source object
|
|
50
|
+
* @returns {object} Merged object
|
|
51
|
+
*/
|
|
52
|
+
export function deepMerge(target, source) {
|
|
53
|
+
const output = Object.assign({}, target)
|
|
54
|
+
if (isObject(target) && isObject(source)) {
|
|
55
|
+
Object.keys(source).forEach(key => {
|
|
56
|
+
if (isObject(source[key])) {
|
|
57
|
+
if (!(key in target)) {
|
|
58
|
+
Object.assign(output, { [key]: source[key] })
|
|
59
|
+
} else {
|
|
60
|
+
output[key] = deepMerge(target[key], source[key])
|
|
61
|
+
}
|
|
62
|
+
} else {
|
|
63
|
+
Object.assign(output, { [key]: source[key] })
|
|
64
|
+
}
|
|
65
|
+
})
|
|
66
|
+
}
|
|
67
|
+
return output
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Validate configuration
|
|
72
|
+
* @param {object} config - Configuration object
|
|
73
|
+
* @returns {object} Validation result
|
|
74
|
+
*/
|
|
75
|
+
export function validateConfig(config) {
|
|
76
|
+
const errors = []
|
|
77
|
+
|
|
78
|
+
if (!config.apiKey || typeof config.apiKey !== 'string') {
|
|
79
|
+
errors.push('apiKey is required and must be a string')
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
if (!config.collectionId || typeof config.collectionId !== 'string') {
|
|
83
|
+
errors.push('collectionId is required and must be a string')
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
if (config.timeout && (typeof config.timeout !== 'number' || config.timeout < 0)) {
|
|
87
|
+
errors.push('timeout must be a positive number')
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
if (config.cacheTTL && (typeof config.cacheTTL !== 'number' || config.cacheTTL < 0)) {
|
|
91
|
+
errors.push('cacheTTL must be a positive number')
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
if (errors.length > 0) {
|
|
95
|
+
return {
|
|
96
|
+
valid: false,
|
|
97
|
+
errors,
|
|
98
|
+
code: ERROR_CODES.INVALID_CONFIG
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
return { valid: true }
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Truncate text to max length
|
|
107
|
+
* @param {string} text - Text to truncate
|
|
108
|
+
* @param {number} maxLength - Maximum length
|
|
109
|
+
* @returns {string} Truncated text
|
|
110
|
+
*/
|
|
111
|
+
export function truncateText(text, maxLength = PERFORMANCE.MAX_CONTENT_SIZE) {
|
|
112
|
+
if (!text || text.length <= maxLength) return text
|
|
113
|
+
logger.warn(`Content truncated from ${text.length} to ${maxLength} characters`)
|
|
114
|
+
return text.substring(0, maxLength)
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Extract domain from URL
|
|
119
|
+
* @param {string} url - URL to extract domain from
|
|
120
|
+
* @returns {string} Domain
|
|
121
|
+
*/
|
|
122
|
+
export function extractDomain(url) {
|
|
123
|
+
try {
|
|
124
|
+
const urlObj = new URL(url)
|
|
125
|
+
return urlObj.hostname
|
|
126
|
+
} catch (e) {
|
|
127
|
+
return ''
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Sanitize text content
|
|
133
|
+
* @param {string} text - Text to sanitize
|
|
134
|
+
* @returns {string} Sanitized text
|
|
135
|
+
*/
|
|
136
|
+
export function sanitizeText(text) {
|
|
137
|
+
if (!text) return ''
|
|
138
|
+
return text
|
|
139
|
+
.replace(/\s+/g, ' ') // Normalize whitespace
|
|
140
|
+
.replace(/[\r\n\t]/g, ' ') // Remove newlines and tabs
|
|
141
|
+
.trim()
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Check if running in browser environment
|
|
146
|
+
* @returns {boolean}
|
|
147
|
+
*/
|
|
148
|
+
export function isBrowser() {
|
|
149
|
+
return typeof window !== 'undefined' && typeof document !== 'undefined'
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Parse JSON safely
|
|
154
|
+
* @param {string} json - JSON string
|
|
155
|
+
* @param {*} fallback - Fallback value
|
|
156
|
+
* @returns {*} Parsed JSON or fallback
|
|
157
|
+
*/
|
|
158
|
+
export function safeJSONParse(json, fallback = null) {
|
|
159
|
+
try {
|
|
160
|
+
return JSON.parse(json)
|
|
161
|
+
} catch (e) {
|
|
162
|
+
logger.warn('Failed to parse JSON:', e)
|
|
163
|
+
return fallback
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Retry a function with exponential backoff
|
|
169
|
+
* @param {Function} fn - Function to retry
|
|
170
|
+
* @param {number} maxAttempts - Maximum number of attempts
|
|
171
|
+
* @param {number} delay - Initial delay in ms
|
|
172
|
+
* @returns {Promise} Result of function
|
|
173
|
+
*/
|
|
174
|
+
export async function retryWithBackoff(fn, maxAttempts = 3, delay = 100) {
|
|
175
|
+
let lastError
|
|
176
|
+
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
|
177
|
+
try {
|
|
178
|
+
return await fn()
|
|
179
|
+
} catch (error) {
|
|
180
|
+
lastError = error
|
|
181
|
+
if (attempt < maxAttempts) {
|
|
182
|
+
const backoffDelay = delay * Math.pow(2, attempt - 1)
|
|
183
|
+
logger.warn(`Attempt ${attempt} failed, retrying in ${backoffDelay}ms...`)
|
|
184
|
+
await new Promise(resolve => setTimeout(resolve, backoffDelay))
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
throw lastError
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Create a timeout promise
|
|
193
|
+
* @param {number} ms - Timeout in milliseconds
|
|
194
|
+
* @returns {Promise} Timeout promise
|
|
195
|
+
*/
|
|
196
|
+
export function timeout(ms) {
|
|
197
|
+
return new Promise((_, reject) => {
|
|
198
|
+
setTimeout(() => reject(new Error('Timeout')), ms)
|
|
199
|
+
})
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* Race a promise against a timeout
|
|
204
|
+
* @param {Promise} promise - Promise to race
|
|
205
|
+
* @param {number} ms - Timeout in milliseconds
|
|
206
|
+
* @returns {Promise} Result or timeout error
|
|
207
|
+
*/
|
|
208
|
+
export function withTimeout(promise, ms) {
|
|
209
|
+
return Promise.race([promise, timeout(ms)])
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* Format taxonomy path
|
|
214
|
+
* @param {array} path - Taxonomy path array
|
|
215
|
+
* @returns {string} Formatted path
|
|
216
|
+
*/
|
|
217
|
+
export function formatTaxonomyPath(path) {
|
|
218
|
+
if (!Array.isArray(path) || path.length === 0) return ''
|
|
219
|
+
return path.join('/')
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* Extract keywords from text
|
|
224
|
+
* @param {string} text - Text to extract keywords from
|
|
225
|
+
* @param {number} maxKeywords - Maximum number of keywords
|
|
226
|
+
* @returns {array} Array of keywords
|
|
227
|
+
*/
|
|
228
|
+
export function extractKeywords(text, maxKeywords = 10) {
|
|
229
|
+
if (!text) return []
|
|
230
|
+
|
|
231
|
+
// Simple keyword extraction (in production, use NLP libraries)
|
|
232
|
+
const words = text.toLowerCase()
|
|
233
|
+
.replace(/[^a-z0-9\s]/g, ' ')
|
|
234
|
+
.split(/\s+/)
|
|
235
|
+
.filter(word => word.length > 3)
|
|
236
|
+
|
|
237
|
+
// Get unique words
|
|
238
|
+
const uniqueWords = [...new Set(words)]
|
|
239
|
+
|
|
240
|
+
// Return top N keywords
|
|
241
|
+
return uniqueWords.slice(0, maxKeywords)
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* Get current timestamp
|
|
246
|
+
* @returns {number} Timestamp in seconds
|
|
247
|
+
*/
|
|
248
|
+
export function getTimestamp() {
|
|
249
|
+
return Math.floor(Date.now() / 1000)
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* Check if value is expired
|
|
254
|
+
* @param {number} timestamp - Timestamp in seconds
|
|
255
|
+
* @param {number} ttl - TTL in seconds
|
|
256
|
+
* @returns {boolean} True if expired
|
|
257
|
+
*/
|
|
258
|
+
export function isExpired(timestamp, ttl) {
|
|
259
|
+
return getTimestamp() - timestamp > ttl
|
|
260
|
+
}
|
|
261
|
+
|