228 lines
9.1 KiB
JavaScript
228 lines
9.1 KiB
JavaScript
#!/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}..."`);
|
||
}
|
||
})();
|