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