Spaces:
Running
Running
| const fs = require('fs'); | |
| const path = require('path'); | |
| const zernioPoster = require('./zernio-poster'); | |
| const postProxyPoster = require('./postproxy-poster'); | |
| const apiCaller = require('./skills/api_caller'); | |
| const DB_PATH = path.join(__dirname, 'database/social_db.json'); | |
| class AnalyticsEngine { | |
| _loadDB() { | |
| return JSON.parse(fs.readFileSync(DB_PATH, 'utf8')); | |
| } | |
| _saveDB(db) { | |
| fs.writeFileSync(DB_PATH, JSON.stringify(db, null, 2)); | |
| } | |
| /** | |
| * Pull analytics for all published posts from Zernio + PostProxy. | |
| */ | |
| async pullDailyAnalytics() { | |
| console.log('[Analytics] Pulling daily analytics...'); | |
| const db = this._loadDB(); | |
| const published = db.posts.filter(p => p.status === 'published' || p.status === 'scheduled'); | |
| let updated = 0; | |
| for (const post of published) { | |
| const cp = post.channel_posts || {}; | |
| let totalLikes = 0, totalComments = 0, totalShares = 0, totalReach = 0; | |
| for (const [channel, info] of Object.entries(cp)) { | |
| if (info.status !== 'published' || !info.postId) continue; | |
| try { | |
| let analytics = null; | |
| const zernioChs = ['instagram', 'threads', 'facebook', 'pinterest']; | |
| if (zernioChs.includes(channel)) { | |
| const result = await zernioPoster.getAnalytics(info.postId); | |
| if (result.success) analytics = result.analytics; | |
| } else { | |
| // PostProxy channels | |
| const result = await postProxyPoster.getPostAnalytics(info.postId); | |
| if (result.success) analytics = result.analytics; | |
| } | |
| if (analytics) { | |
| info.analytics = analytics; | |
| totalLikes += analytics.likes || 0; | |
| totalComments += analytics.comments || 0; | |
| totalShares += analytics.shares || 0; | |
| totalReach += analytics.reach || analytics.impressions || 0; | |
| } | |
| } catch (e) { | |
| console.error(`[Analytics] Failed for ${post.id}/${channel}: ${e.message}`); | |
| } | |
| } | |
| // Aggregate per-post analytics | |
| if (totalLikes || totalComments || totalShares) { | |
| const reach = totalReach || 1; | |
| post.analytics = { | |
| likes: totalLikes, | |
| comments: totalComments, | |
| shares: totalShares, | |
| reach: totalReach, | |
| engagementRate: ((totalLikes + totalComments + totalShares) / reach * 100).toFixed(2), | |
| score: this.scorePost({ likes: totalLikes, comments: totalComments, shares: totalShares, reach: totalReach }), | |
| lastSync: new Date().toISOString() | |
| }; | |
| updated++; | |
| } | |
| } | |
| // Compute patterns | |
| if (updated > 0 || published.length > 0) { | |
| db.analytics_patterns = this._computePatterns(published); | |
| } | |
| this._saveDB(db); | |
| console.log(`[Analytics] Updated ${updated} posts, patterns computed.`); | |
| return { success: true, updated }; | |
| } | |
| /** | |
| * Score a post based on engagement metrics. | |
| */ | |
| scorePost(metrics) { | |
| const { likes = 0, comments = 0, shares = 0, reach = 1 } = metrics; | |
| // Weighted: comments 3x, shares 2x, likes 1x | |
| return parseFloat(((comments * 3 + shares * 2 + likes) / Math.max(reach, 1) * 100).toFixed(2)); | |
| } | |
| /** | |
| * Compute patterns from published posts. | |
| */ | |
| _computePatterns(posts) { | |
| const withAnalytics = posts.filter(p => p.analytics); | |
| if (withAnalytics.length === 0) { | |
| return { last_computed: new Date().toISOString(), insights_text: 'No analytics data yet.' }; | |
| } | |
| // Group by pillar | |
| const pillarScores = {}; | |
| const hookScores = {}; | |
| const timeScores = {}; | |
| for (const post of withAnalytics) { | |
| const pillar = post.v1?.pillar || 'unknown'; | |
| if (!pillarScores[pillar]) pillarScores[pillar] = []; | |
| pillarScores[pillar].push(post.analytics.score || 0); | |
| const time = post.v1?.best_time_to_post || 'unknown'; | |
| if (!timeScores[time]) timeScores[time] = []; | |
| timeScores[time].push(post.analytics.score || 0); | |
| } | |
| const avg = arr => arr.length ? (arr.reduce((a, b) => a + b, 0) / arr.length).toFixed(2) : 0; | |
| const bestPillar = Object.entries(pillarScores).sort((a, b) => avg(b[1]) - avg(a[1]))[0]; | |
| const bestTime = Object.entries(timeScores).sort((a, b) => avg(b[1]) - avg(a[1]))[0]; | |
| const topPosts = withAnalytics | |
| .sort((a, b) => (b.analytics.score || 0) - (a.analytics.score || 0)) | |
| .slice(0, 5) | |
| .map(p => ({ id: p.id, title: p.v1?.title, score: p.analytics.score, pillar: p.v1?.pillar })); | |
| // Auto-offer trigger: high-scoring posts without offers get one created (fire-and-forget) | |
| try { | |
| const salesEngine = require('./skills/sales_engine'); | |
| const postsToOffer = withAnalytics.filter(p => (p.analytics.score || 0) > 3 && !p.offerCreated); | |
| if (postsToOffer.length > 0) { | |
| const self = this; | |
| (async () => { | |
| for (const post of postsToOffer) { | |
| try { | |
| console.log(`[Analytics] Auto-offer trigger: ${post.v1?.title || post.id} (score: ${post.analytics.score})`); | |
| const offerResult = await salesEngine.createOfferFromWinner(post); | |
| post.offerCreated = true; | |
| post.offerResult = offerResult.success ? offerResult.offer?.id : offerResult.verdict; | |
| } catch (e) { console.error(`[Analytics] Offer failed for ${post.id}:`, e.message); } | |
| } | |
| self._saveDB(db); | |
| })(); | |
| } | |
| } catch (e) { | |
| console.error('[Analytics] Auto-offer trigger error:', e.message); | |
| } | |
| const insightsText = [ | |
| bestPillar ? `Best pillar: "${bestPillar[0]}" (avg score: ${avg(bestPillar[1])})` : '', | |
| bestTime ? `Best posting time: ${bestTime[0]}` : '', | |
| `Top post: "${topPosts[0]?.title || 'N/A'}" (score: ${topPosts[0]?.score || 0})`, | |
| `Total analyzed: ${withAnalytics.length} posts` | |
| ].filter(Boolean).join('. '); | |
| return { | |
| last_computed: new Date().toISOString(), | |
| best_pillar: bestPillar ? bestPillar[0] : null, | |
| best_posting_time: bestTime ? bestTime[0] : null, | |
| top_posts: topPosts, | |
| insights_text: insightsText | |
| }; | |
| } | |
| /** | |
| * Generate a Telegram-friendly daily summary. | |
| */ | |
| async generateDailySummary() { | |
| const db = this._loadDB(); | |
| const patterns = db.analytics_patterns; | |
| if (!patterns || patterns.insights_text === 'No analytics data yet.') { | |
| return { success: true, message: 'No analytics data yet. Publish some posts first!' }; | |
| } | |
| const published = db.posts.filter(p => p.status === 'published'); | |
| const withAnalytics = published.filter(p => p.analytics); | |
| let msg = `📊 <b>Daily Analytics Summary</b>\n\n`; | |
| msg += `<b>Posts Published:</b> ${published.length}\n`; | |
| msg += `<b>With Analytics:</b> ${withAnalytics.length}\n\n`; | |
| if (patterns.best_pillar) msg += `🏆 <b>Best Pillar:</b> ${patterns.best_pillar}\n`; | |
| if (patterns.best_posting_time) msg += `⏰ <b>Best Time:</b> ${patterns.best_posting_time}\n`; | |
| if (patterns.top_posts?.length > 0) { | |
| msg += `\n<b>Top Performers:</b>\n`; | |
| for (const tp of patterns.top_posts.slice(0, 3)) { | |
| msg += `• ${tp.title || tp.id} — Score: ${tp.score}\n`; | |
| } | |
| } | |
| // Send via Telegram | |
| if (process.env.TELEGRAM_CHAT_ID) { | |
| await apiCaller.sendTelegramMessage(process.env.TELEGRAM_CHAT_ID, msg); | |
| } | |
| return { success: true, message: msg }; | |
| } | |
| /** | |
| * Get compact insights string for AI remix prompt injection. | |
| */ | |
| getInsightsForAI() { | |
| try { | |
| const db = this._loadDB(); | |
| return db.analytics_patterns?.insights_text || ''; | |
| } catch (e) { | |
| return ''; | |
| } | |
| } | |
| /** | |
| * Get analytics data for dashboard. | |
| */ | |
| getDashboardData() { | |
| const db = this._loadDB(); | |
| const published = db.posts.filter(p => p.analytics); | |
| return { | |
| patterns: db.analytics_patterns || null, | |
| posts: published.map(p => ({ | |
| id: p.id, | |
| title: p.v1?.title, | |
| pillar: p.v1?.pillar, | |
| ts: p.ts, | |
| analytics: p.analytics | |
| })) | |
| }; | |
| } | |
| } | |
| module.exports = new AnalyticsEngine(); | |