Files
olliewazza_larry/scripts/check-analytics.js

228 lines
9.1 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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}..."`);
}
})();