also looking at this
Sync Bluesky engagement metrics into analytics
#12
10 files
+200
-4
| @@ -34,7 +34,7 @@ export default new Action({ | ||
| 34 | 34 | |
| 35 | 35 | const targets = await database |
| 36 | 36 | .selectFrom('post_targets') |
| 37 | .select(['provider', 'status', 'updated_at']) | |
| 37 | .select(['provider', 'status', 'created_at', 'metrics']) | |
| 38 | 38 | .where('status', '=', 'published') |
| 39 | 39 | .execute() |
| 40 | 40 | |
| @@ -77,10 +77,11 @@ export default new Action({ | ||
| 77 | 77 | share: totalTargets ? Math.round((count / totalTargets) * 100) : 0, |
| 78 | 78 | })) |
| 79 | 79 | |
| 80 | // Publish-hour histogram (UTC) → best posting windows. | |
| 80 | // Publish-hour histogram (UTC) → best posting windows. Uses | |
| 81 | // created_at: metric syncs bump updated_at, which would skew it. | |
| 81 | 82 | const byHour = new Map<number, number>() |
| 82 | 83 | for (const target of targets) { |
| 83 | const date = parseUtc(target.updated_at) | |
| 84 | const date = parseUtc(target.created_at) | |
| 84 | 85 | if (date) byHour.set(date.getUTCHours(), (byHour.get(date.getUTCHours()) || 0) + 1) |
| 85 | 86 | } |
| 86 | 87 | const windows = [...byHour.entries()] |
| @@ -88,6 +89,20 @@ export default new Action({ | ||
| 88 | 89 | .slice(0, 3) |
| 89 | 90 | .map(([hour, count]) => ({ hour, count })) |
| 90 | 91 | |
| 92 | // Engagement totals from synced post_targets.metrics. | |
| 93 | const engagement = { likes: 0, reposts: 0, replies: 0, syncedTargets: 0 } | |
| 94 | for (const target of targets) { | |
| 95 | if (!target.metrics) continue | |
| 96 | try { | |
| 97 | const metrics = JSON.parse(String(target.metrics)) | |
| 98 | engagement.likes += Number(metrics.likes) || 0 | |
| 99 | engagement.reposts += Number(metrics.reposts) || 0 | |
| 100 | engagement.replies += Number(metrics.replies) || 0 | |
| 101 | engagement.syncedTargets += 1 | |
| 102 | } | |
| 103 | catch {} | |
| 104 | } | |
| 105 | ||
| 91 | 106 | return response.json({ |
| 92 | 107 | ok: true, |
| 93 | 108 | data: { |
| @@ -96,6 +111,7 @@ export default new Action({ | ||
| 96 | 111 | postsLastWeek, |
| 97 | 112 | bestChannel: channelMix[0] || null, |
| 98 | 113 | bestHour: windows[0] || null, |
| 114 | engagement, | |
| 99 | 115 | }, |
| 100 | 116 | series, |
| 101 | 117 | channelMix, |
| @@ -0,0 +1,22 @@ | ||
| 1 | import { Action } from '@stacksjs/actions' | |
| 2 | import { response } from '@stacksjs/router' | |
| 3 | import { bluesky } from '../../Services/Social/BlueskyService' | |
| 4 | ||
| 5 | export default new Action({ | |
| 6 | name: 'Postline Metrics Sync', | |
| 7 | description: 'Refresh engagement metrics for published posts on demand.', | |
| 8 | method: 'POST', | |
| 9 | ||
| 10 | async handle() { | |
| 11 | try { | |
| 12 | const data = await bluesky.syncMetrics() | |
| 13 | return response.json({ ok: true, data }) | |
| 14 | } | |
| 15 | catch (error) { | |
| 16 | return response.json({ | |
| 17 | ok: false, | |
| 18 | error: error instanceof Error ? error.message : String(error), | |
| 19 | }, { status: 422 }) | |
| 20 | } | |
| 21 | }, | |
| 22 | }) | |
| @@ -0,0 +1,24 @@ | ||
| 1 | import { Job } from '@stacksjs/queue' | |
| 2 | import { Every } from '@stacksjs/types' | |
| 3 | import { bluesky } from '../Services/Social/BlueskyService' | |
| 4 | ||
| 5 | export default new Job({ | |
| 6 | name: 'SyncEngagementMetrics', | |
| 7 | description: 'Refresh Bluesky engagement counts into post_targets.metrics', | |
| 8 | queue: 'default', | |
| 9 | tries: 1, | |
| 10 | backoff: 3, | |
| 11 | rate: Every.HalfHour, | |
| 12 | ||
| 13 | handle: async () => { | |
| 14 | try { | |
| 15 | const { synced } = await bluesky.syncMetrics() | |
| 16 | if (synced) console.log(`[postline] engagement sync: ${synced} targets refreshed`) | |
| 17 | return { synced } | |
| 18 | } | |
| 19 | catch (error) { | |
| 20 | // No connected account yet is normal on fresh installs — stay quiet. | |
| 21 | return { synced: 0, skipped: error instanceof Error ? error.message : String(error) } | |
| 22 | } | |
| 23 | }, | |
| 24 | }) | |
| @@ -21,6 +21,11 @@ export default function () { | ||
| 21 | 21 | .job('PublishScheduledPosts') |
| 22 | 22 | .everyMinute() |
| 23 | 23 | |
| 24 | // Refresh Bluesky engagement counts for the analytics page | |
| 25 | schedule | |
| 26 | .job('SyncEngagementMetrics') | |
| 27 | .everyThirtyMinutes() | |
| 28 | ||
| 24 | 29 | // Run a custom action every five minutes |
| 25 | 30 | // schedule.action('CleanupTempFiles').everyFiveMinutes() |
| 26 | 31 | |
| @@ -273,6 +273,58 @@ export class BlueskyService { | ||
| 273 | 273 | } |
| 274 | 274 | } |
| 275 | 275 | |
| 276 | /** | |
| 277 | * Refresh engagement counts (likes/reposts/replies) for recently | |
| 278 | * published Bluesky targets into `post_targets.metrics`. Batched 25 | |
| 279 | * URIs per API call; missing posts (deleted upstream) are skipped. | |
| 280 | */ | |
| 281 | async syncMetrics(limit = 100): Promise<{ synced: number }> { | |
| 282 | const identity = await this.requireIdentity() | |
| 283 | await this.ensureDriver() | |
| 284 | ||
| 285 | const targets = await database | |
| 286 | .selectFrom('post_targets') | |
| 287 | .select(['id', 'remote_uri']) | |
| 288 | .where('provider', '=', 'bluesky') | |
| 289 | .where('status', '=', 'published') | |
| 290 | .where('remote_uri', 'like', 'at://%') | |
| 291 | .orderBy('id', 'desc') | |
| 292 | .limit(limit) | |
| 293 | .execute() | |
| 294 | ||
| 295 | if (targets.length === 0) return { synced: 0 } | |
| 296 | ||
| 297 | let synced = 0 | |
| 298 | for (let offset = 0; offset < targets.length; offset += 25) { | |
| 299 | const chunk = targets.slice(offset, offset + 25) | |
| 300 | const metrics = await this.withFreshSession(identity, freshIdentity => | |
| 301 | this.driver.postMetrics({ | |
| 302 | handle: freshIdentity.handle, | |
| 303 | did: freshIdentity.external_id || undefined, | |
| 304 | accessToken: freshIdentity.access_token || undefined, | |
| 305 | refreshToken: freshIdentity.refresh_token || undefined, | |
| 306 | }, chunk.map((target: any) => String(target.remote_uri))), | |
| 307 | ) | |
| 308 | ||
| 309 | const byUri = new Map(metrics.map(item => [item.uri, item])) | |
| 310 | for (const target of chunk) { | |
| 311 | const counts = byUri.get(String(target.remote_uri)) | |
| 312 | if (!counts) continue | |
| 313 | await database.updateTable('post_targets').set({ | |
| 314 | metrics: JSON.stringify({ | |
| 315 | likes: counts.likeCount, | |
| 316 | reposts: counts.repostCount, | |
| 317 | replies: counts.replyCount, | |
| 318 | syncedAt: now(), | |
| 319 | }), | |
| 320 | }).where('id', '=', target.id).execute() | |
| 321 | synced += 1 | |
| 322 | } | |
| 323 | } | |
| 324 | ||
| 325 | return { synced } | |
| 326 | } | |
| 327 | ||
| 276 | 328 | async syncTimeline(limit = 30): Promise<TimelineResult & { saved: number }> { |
| 277 | 329 | const identity = await this.requireIdentity() |
| 278 | 330 | const driver = await this.ensureDriver() |
| @@ -36,6 +36,7 @@ interface AnalyticsPayload { | ||
| 36 | 36 | postsLastWeek: number |
| 37 | 37 | bestChannel: { provider: string, published: number, share: number } | null |
| 38 | 38 | bestHour: { hour: number, count: number } | null |
| 39 | engagement?: { likes: number, reposts: number, replies: number, syncedTargets: number } | |
| 39 | 40 | } |
| 40 | 41 | series: Point[] |
| 41 | 42 | channelMix: Array<{ provider: string, published: number, share: number }> |
| @@ -254,6 +255,16 @@ function fillStats(payload: AnalyticsPayload): void { | ||
| 254 | 255 | |
| 255 | 256 | set('[data-analytics-posts-week]', String(stats.postsThisWeek)) |
| 256 | 257 | set('[data-analytics-posts-trend]', `${delta >= 0 ? '+' : ''}${delta} from last week`) |
| 258 | ||
| 259 | const engagement = stats.engagement | |
| 260 | const interactions = engagement ? engagement.likes + engagement.reposts + engagement.replies : 0 | |
| 261 | set('[data-analytics-engagement]', engagement?.syncedTargets ? String(interactions) : '—') | |
| 262 | set( | |
| 263 | '[data-analytics-engagement-trend]', | |
| 264 | engagement?.syncedTargets | |
| 265 | ? `${engagement.likes} likes · ${engagement.reposts} reposts · ${engagement.replies} replies` | |
| 266 | : 'Syncs after publishing to Bluesky', | |
| 267 | ) | |
| 257 | 268 | set('[data-analytics-best-channel]', stats.bestChannel ? (providerNames[stats.bestChannel.provider] || stats.bestChannel.provider) : '—') |
| 258 | 269 | set('[data-analytics-best-channel-trend]', stats.bestChannel ? `${stats.bestChannel.published} published posts` : 'No published posts yet') |
| 259 | 270 | set('[data-analytics-best-window]', stats.bestHour ? formatHour(stats.bestHour.hour) : '—') |
| @@ -13,12 +13,17 @@ | ||
| 13 | 13 | </div> |
| 14 | 14 | </header> |
| 15 | 15 | |
| 16 | <div class="grid gap-3.5 lg:grid-cols-3"> | |
| 16 | <div class="grid gap-3.5 sm:grid-cols-2 lg:grid-cols-4"> | |
| 17 | 17 | <article class="grid gap-2 p-3.5 bg-white border border-zinc-200 rounded-2xl shadow-sm"> |
| 18 | 18 | <span class="text-xs text-zinc-500">Posts this week</span> |
| 19 | 19 | <strong class="text-2xl" data-analytics-posts-week>—</strong> |
| 20 | 20 | <span class="text-xs text-zinc-500" data-analytics-posts-trend>Loading…</span> |
| 21 | 21 | </article> |
| 22 | <article class="grid gap-2 p-3.5 bg-white border border-zinc-200 rounded-2xl shadow-sm"> | |
| 23 | <span class="text-xs text-zinc-500">Engagement</span> | |
| 24 | <strong class="text-2xl" data-analytics-engagement>—</strong> | |
| 25 | <span class="text-xs text-zinc-500" data-analytics-engagement-trend>Loading…</span> | |
| 26 | </article> | |
| 22 | 27 | <article class="grid gap-2 p-3.5 bg-white border border-zinc-200 rounded-2xl shadow-sm"> |
| 23 | 28 | <span class="text-xs text-zinc-500">Best channel</span> |
| 24 | 29 | <strong class="text-2xl" data-analytics-best-channel>—</strong> |
| @@ -61,4 +61,5 @@ route.group({ prefix: '/postline' }, () => { | ||
| 61 | 61 | route.post('/queue', 'Actions/Postline/QueueSaveAction').middleware('auth').skipCsrf() |
| 62 | 62 | route.post('/queue/delete', 'Actions/Postline/QueueDeleteAction').middleware('auth').skipCsrf() |
| 63 | 63 | route.post('/queue/publish-now', 'Actions/Postline/QueuePublishNowAction').middleware('auth').skipCsrf() |
| 64 | route.post('/metrics/sync', 'Actions/Postline/MetricsSyncAction').middleware('auth').skipCsrf() | |
| 64 | 65 | }) |