Files
olliewazza_larry/scripts/check-analytics.js

228 lines
9.1 KiB
JavaScript
Raw Permalink Normal View History

#!/usr/bin/env node
/**
* TikTok Analytics Checker
*
* Connects Postiz posts to their TikTok video IDs and pulls per-post analytics.
*
* How it works:
* 1. Fetches all Postiz posts in the date range
* 2. For posts with releaseId="missing", calls /posts/{id}/missing to get TikTok video list
* 3. Matches posts to videos chronologically (TikTok IDs are sequential: higher = newer)
* 4. Connects each post to its TikTok video via PUT /posts/{id}/release-id
* 5. Pulls per-post analytics (views, likes, comments, shares)
*
* IMPORTANT: TikTok's API takes 1-2 hours to index new videos. Don't run this
* on posts published less than 2 hours ago the video won't be in the list yet.
* The daily cron runs in the morning, checking posts from the last 3 days, which
* avoids this timing issue entirely.
*
* Usage: node check-analytics.js --config <config.json> [--days 3] [--connect] [--app snugly]
*
* --connect: Actually connect release IDs (without this flag, it's dry-run)
* --app: Filter to a specific app/integration name
* --days: How many days back to check (default: 3)
*/
const fs = require('fs');
const path = require('path');
const args = process.argv.slice(2);
function getArg(name) {
const idx = args.indexOf(`--${name}`);
return idx !== -1 ? args[idx + 1] : null;
}
const configPath = getArg('config');
const days = parseInt(getArg('days') || '3');
const shouldConnect = args.includes('--connect');
const appFilter = getArg('app');
if (!configPath) {
console.error('Usage: node check-analytics.js --config <config.json> [--days 3] [--connect] [--app name]');
process.exit(1);
}
const config = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
const BASE_URL = 'https://api.postiz.com/public/v1';
const API_KEY = config.postiz.apiKey;
async function api(method, endpoint, body = null) {
const opts = {
method,
headers: { 'Authorization': API_KEY, 'Content-Type': 'application/json' }
};
if (body) opts.body = JSON.stringify(body);
const res = await fetch(`${BASE_URL}${endpoint}`, opts);
return res.json();
}
async function sleep(ms) { return new Promise(r => setTimeout(r, ms)); }
(async () => {
const now = new Date();
const startDate = new Date(now - days * 86400000);
// Don't check posts from the last 2 hours (TikTok indexing delay)
const cutoffDate = new Date(now - 2 * 3600000);
console.log(`📊 Checking analytics (last ${days} days, cutoff: posts before ${cutoffDate.toISOString().slice(11, 16)} UTC)\n`);
// 1. Get all posts in range
const postsData = await api('GET', `/posts?startDate=${startDate.toISOString()}&endDate=${now.toISOString()}`);
let posts = postsData.posts || [];
// Filter by app if specified
if (appFilter) {
posts = posts.filter(p => p.integration?.name?.toLowerCase().includes(appFilter.toLowerCase()));
}
// Filter to TikTok posts only
posts = posts.filter(p => p.integration?.providerIdentifier === 'tiktok');
// Sort by publish date (oldest first)
posts.sort((a, b) => new Date(a.publishDate) - new Date(b.publishDate));
console.log(`Found ${posts.length} TikTok posts\n`);
// 2. Separate connected vs unconnected
const connected = posts.filter(p => p.releaseId && p.releaseId !== 'missing');
const unconnected = posts.filter(p => !p.releaseId || p.releaseId === 'missing');
// Filter unconnected to only posts older than 2 hours
const connectableUnconnected = unconnected.filter(p => new Date(p.publishDate) < cutoffDate);
const tooNew = unconnected.filter(p => new Date(p.publishDate) >= cutoffDate);
console.log(` Connected: ${connected.length}`);
console.log(` Unconnected (ready): ${connectableUnconnected.length}`);
if (tooNew.length > 0) {
console.log(` Too new (< 2h, skipping): ${tooNew.length}`);
tooNew.forEach(p => console.log(` ⏳ "${(p.content || '').substring(0, 50)}..." — wait for TikTok to index`));
}
console.log('');
// 3. If there are connectable unconnected posts, get the TikTok video list
if (connectableUnconnected.length > 0 && shouldConnect) {
// Use the first unconnected post to get the missing list
const referencePost = connectableUnconnected[0];
console.log(`🔍 Fetching TikTok video list via post ${referencePost.id}...`);
const tiktokVideos = await api('GET', `/posts/${referencePost.id}/missing`);
if (Array.isArray(tiktokVideos) && tiktokVideos.length > 0) {
// TikTok IDs are sequential (higher = newer). Sort ascending.
const videoIds = tiktokVideos.map(v => v.id).sort();
// Get already-connected IDs to exclude them
const connectedIds = new Set(connected.map(p => p.releaseId));
const availableIds = videoIds.filter(id => !connectedIds.has(id));
console.log(` Found ${videoIds.length} TikTok videos, ${availableIds.length} unconnected\n`);
// Sort unconnected posts by publish date (oldest first)
// Sort available IDs ascending (oldest first)
// Match them up chronologically
const sortedAvailable = availableIds.sort();
// We need to match the N most recent available IDs to the N unconnected posts
// Take the last N available IDs (newest) to match with the unconnected posts
const idsToUse = sortedAvailable.slice(-connectableUnconnected.length);
for (let i = 0; i < connectableUnconnected.length; i++) {
const post = connectableUnconnected[i];
const videoId = idsToUse[i];
if (!videoId) {
console.log(` ⚠️ No matching video ID for "${(post.content || '').substring(0, 50)}..."`);
continue;
}
console.log(` 🔗 Connecting: "${(post.content || '').substring(0, 50)}..."`);
console.log(` Post: ${post.id} (${post.publishDate})`);
console.log(` TikTok: ${videoId}`);
const result = await api('PUT', `/posts/${post.id}/release-id`, { releaseId: videoId });
if (result.releaseId === videoId) {
console.log(` ✅ Connected`);
} else {
console.log(` ⚠️ Connection returned: ${JSON.stringify(result.releaseId)}`);
}
await sleep(1000);
}
console.log('');
} else {
console.log(` ⚠️ No TikTok videos found in missing list. Videos may need more time to index.\n`);
}
} else if (connectableUnconnected.length > 0 && !shouldConnect) {
console.log(` ${connectableUnconnected.length} posts need connecting. Run with --connect to auto-connect.\n`);
}
// 4. Pull analytics for all connected posts
console.log('📈 Per-Post Analytics:\n');
// Re-fetch posts to get updated release IDs
const updatedData = await api('GET', `/posts?startDate=${startDate.toISOString()}&endDate=${now.toISOString()}`);
let updatedPosts = (updatedData.posts || []).filter(p =>
p.integration?.providerIdentifier === 'tiktok' &&
p.releaseId && p.releaseId !== 'missing'
);
if (appFilter) {
updatedPosts = updatedPosts.filter(p => p.integration?.name?.toLowerCase().includes(appFilter.toLowerCase()));
}
updatedPosts.sort((a, b) => new Date(b.publishDate) - new Date(a.publishDate)); // newest first
const results = [];
for (const post of updatedPosts) {
const analytics = await api('GET', `/analytics/post/${post.id}`);
const metrics = {};
if (Array.isArray(analytics)) {
analytics.forEach(m => {
const latest = m.data?.[m.data.length - 1];
if (latest) metrics[m.label.toLowerCase()] = parseInt(latest.total) || 0;
});
}
const result = {
id: post.id,
date: post.publishDate?.slice(0, 10),
hook: (post.content || '').substring(0, 60),
app: post.integration?.name,
views: metrics.views || 0,
likes: metrics.likes || 0,
comments: metrics.comments || 0,
shares: metrics.shares || 0,
releaseId: post.releaseId
};
results.push(result);
const viewStr = result.views > 1000 ? `${(result.views / 1000).toFixed(1)}K` : result.views;
console.log(` ${result.date} | ${viewStr} views | ${result.likes} likes | ${result.comments} comments | ${result.shares} shares`);
console.log(` "${result.hook}..."`);
console.log(` ${result.app} | TikTok: ${result.releaseId}\n`);
await sleep(500);
}
// 5. Save results
const baseDir = path.dirname(configPath);
const analyticsPath = path.join(baseDir, 'analytics-snapshot.json');
const snapshot = {
date: now.toISOString(),
posts: results
};
fs.writeFileSync(analyticsPath, JSON.stringify(snapshot, null, 2));
console.log(`💾 Saved analytics snapshot to ${analyticsPath}`);
// 6. Summary
console.log('\n📊 Summary:');
const totalViews = results.reduce((s, r) => s + r.views, 0);
const totalLikes = results.reduce((s, r) => s + r.likes, 0);
console.log(` Total views: ${totalViews.toLocaleString()}`);
console.log(` Total likes: ${totalLikes.toLocaleString()}`);
console.log(` Posts tracked: ${results.length}`);
if (results.length > 0) {
const best = results.reduce((a, b) => a.views > b.views ? a : b);
const worst = results.reduce((a, b) => a.views < b.views ? a : b);
console.log(` Best: ${best.views.toLocaleString()} views — "${best.hook}..."`);
console.log(` Worst: ${worst.views.toLocaleString()} views — "${worst.hook}..."`);
}
})();