Initial commit with translated description
This commit is contained in:
192
scripts/add-text-overlay.js
Normal file
192
scripts/add-text-overlay.js
Normal file
@@ -0,0 +1,192 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Add text overlays to slideshow images using node-canvas.
|
||||
*
|
||||
* Usage: node add-text-overlay.js --input <dir> --texts <texts.json>
|
||||
*
|
||||
* texts.json format:
|
||||
* [
|
||||
* "Slide 1 text with manual\nline breaks preferred",
|
||||
* "Slide 2 text",
|
||||
* ... 6 total
|
||||
* ]
|
||||
*
|
||||
* TEXT RULES:
|
||||
* - Use \n for manual line breaks (PREFERRED — gives you control)
|
||||
* - If no \n provided, the script auto-wraps to fit within maxWidth
|
||||
* - Keep lines to 4-6 words max for readability
|
||||
* - Text is REACTIONS not labels ("Wait... this is nice??" not "Modern style")
|
||||
* - No emoji (canvas can't render them)
|
||||
*
|
||||
* Reads slide1_raw.png through slide6_raw.png (or slide_1.png etc)
|
||||
* Outputs slide1.png through slide6.png (or final_1.png etc)
|
||||
*/
|
||||
|
||||
const { createCanvas, loadImage } = require('canvas');
|
||||
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 inputDir = getArg('input');
|
||||
const textsPath = getArg('texts');
|
||||
|
||||
if (!inputDir || !textsPath) {
|
||||
console.error('Usage: node add-text-overlay.js --input <dir> --texts <texts.json>');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const texts = JSON.parse(fs.readFileSync(textsPath, 'utf-8'));
|
||||
if (texts.length !== 6) {
|
||||
console.error('ERROR: texts.json must have exactly 6 entries');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
/**
|
||||
* Word-wrap text to fit within maxWidth.
|
||||
* If the text already contains \n, splits on those first,
|
||||
* then wraps any lines that are still too wide.
|
||||
*/
|
||||
function wrapText(ctx, text, maxWidth) {
|
||||
// Strip emoji (canvas can't render them reliably)
|
||||
const cleanText = text.replace(/[\u{1F300}-\u{1FAFF}\u{2600}-\u{27BF}]/gu, '').trim();
|
||||
|
||||
// Split on manual line breaks first
|
||||
const manualLines = cleanText.split('\n');
|
||||
const wrappedLines = [];
|
||||
|
||||
for (const line of manualLines) {
|
||||
// Check if this line fits as-is
|
||||
if (ctx.measureText(line.trim()).width <= maxWidth) {
|
||||
wrappedLines.push(line.trim());
|
||||
continue;
|
||||
}
|
||||
|
||||
// Auto-wrap: split into words, build lines that fit
|
||||
const words = line.trim().split(/\s+/);
|
||||
let currentLine = '';
|
||||
|
||||
for (const word of words) {
|
||||
const testLine = currentLine ? `${currentLine} ${word}` : word;
|
||||
if (ctx.measureText(testLine).width <= maxWidth) {
|
||||
currentLine = testLine;
|
||||
} else {
|
||||
if (currentLine) wrappedLines.push(currentLine);
|
||||
currentLine = word;
|
||||
}
|
||||
}
|
||||
if (currentLine) wrappedLines.push(currentLine);
|
||||
}
|
||||
|
||||
return wrappedLines;
|
||||
}
|
||||
|
||||
async function addTextOverlay(imgPath, text, outPath) {
|
||||
const img = await loadImage(imgPath);
|
||||
const canvas = createCanvas(img.width, img.height);
|
||||
const ctx = canvas.getContext('2d');
|
||||
ctx.drawImage(img, 0, 0);
|
||||
|
||||
// ─── Text settings (match our proven viral format) ───
|
||||
const fontSize = Math.round(img.width * 0.065); // 6.5% of image width (~66px on 1024w)
|
||||
const outlineWidth = Math.round(fontSize * 0.15); // 15% of font size for thick outline
|
||||
const maxWidth = img.width * 0.75; // 75% of image width (padding for TikTok UI)
|
||||
const lineHeight = fontSize * 1.25; // 125% line height for readability
|
||||
|
||||
ctx.font = `bold ${fontSize}px Arial`;
|
||||
ctx.textAlign = 'center';
|
||||
ctx.textBaseline = 'top';
|
||||
|
||||
// Wrap text to fit within maxWidth
|
||||
const lines = wrapText(ctx, text, maxWidth);
|
||||
|
||||
// Calculate vertical position
|
||||
// Center the text block at 30% from top
|
||||
const totalTextHeight = lines.length * lineHeight;
|
||||
const startY = (img.height * 0.30) - (totalTextHeight / 2) + (lineHeight / 2);
|
||||
|
||||
// Ensure text stays in safe zones (not top 10%, not bottom 20%)
|
||||
const minY = img.height * 0.10;
|
||||
const maxY = img.height * 0.80 - totalTextHeight;
|
||||
const safeY = Math.max(minY, Math.min(startY, maxY));
|
||||
|
||||
const x = img.width / 2; // Center horizontally
|
||||
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const y = safeY + (i * lineHeight);
|
||||
|
||||
// Black outline (stroke first, then fill on top)
|
||||
ctx.strokeStyle = '#000000';
|
||||
ctx.lineWidth = outlineWidth;
|
||||
ctx.lineJoin = 'round';
|
||||
ctx.miterLimit = 2;
|
||||
ctx.strokeText(lines[i], x, y);
|
||||
|
||||
// White fill
|
||||
ctx.fillStyle = '#FFFFFF';
|
||||
ctx.fillText(lines[i], x, y);
|
||||
}
|
||||
|
||||
fs.writeFileSync(outPath, canvas.toBuffer('image/png'));
|
||||
|
||||
// Log the actual text layout for debugging
|
||||
console.log(` ✅ ${path.basename(outPath)} — ${lines.length} lines:`);
|
||||
lines.forEach(l => console.log(` "${l}"`));
|
||||
}
|
||||
|
||||
// Find input files (supports multiple naming conventions)
|
||||
function findSlideFile(dir, num) {
|
||||
const candidates = [
|
||||
`slide${num}_raw.png`,
|
||||
`slide_${num}.png`,
|
||||
`slide${num}.png`,
|
||||
`raw_${num}.png`,
|
||||
`${num}.png`
|
||||
];
|
||||
for (const name of candidates) {
|
||||
const p = path.join(dir, name);
|
||||
if (fs.existsSync(p)) return p;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function outputName(dir, num, inputName) {
|
||||
// If input is slide1_raw.png → output slide1.png
|
||||
// If input is slide_1.png → output final_1.png
|
||||
if (inputName.includes('_raw')) {
|
||||
return path.join(dir, inputName.replace('_raw', ''));
|
||||
}
|
||||
if (inputName.startsWith('slide_')) {
|
||||
return path.join(dir, `final_${num}.png`);
|
||||
}
|
||||
return path.join(dir, `slide${num}_final.png`);
|
||||
}
|
||||
|
||||
(async () => {
|
||||
console.log('📝 Adding text overlays...\n');
|
||||
console.log('Settings:');
|
||||
console.log(' Font size: 6.5% of image width');
|
||||
console.log(' Position: centered at ~30% from top');
|
||||
console.log(' Max width: 75% of image');
|
||||
console.log(' Style: white fill, black outline\n');
|
||||
|
||||
let success = 0;
|
||||
for (let i = 0; i < 6; i++) {
|
||||
const num = i + 1;
|
||||
const inputFile = findSlideFile(inputDir, num);
|
||||
if (!inputFile) {
|
||||
console.error(` ❌ Slide ${num}: no input file found in ${inputDir}`);
|
||||
continue;
|
||||
}
|
||||
const outPath = outputName(inputDir, num, path.basename(inputFile));
|
||||
await addTextOverlay(inputFile, texts[i], outPath);
|
||||
success++;
|
||||
}
|
||||
|
||||
console.log(`\n✨ ${success}/6 overlays complete!`);
|
||||
if (success < 6) process.exit(1);
|
||||
})();
|
||||
227
scripts/check-analytics.js
Normal file
227
scripts/check-analytics.js
Normal file
@@ -0,0 +1,227 @@
|
||||
#!/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}..."`);
|
||||
}
|
||||
})();
|
||||
87
scripts/competitor-research.js
Normal file
87
scripts/competitor-research.js
Normal file
@@ -0,0 +1,87 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Competitor Research — Save & Query Findings
|
||||
*
|
||||
* The actual research is done by the agent using the browser.
|
||||
* This script manages the competitor-research.json file.
|
||||
*
|
||||
* Usage:
|
||||
* node competitor-research.js --dir tiktok-marketing/ --summary
|
||||
* node competitor-research.js --dir tiktok-marketing/ --add-competitor '{"name":"AppX","tiktokHandle":"@appx",...}'
|
||||
* node competitor-research.js --dir tiktok-marketing/ --gaps
|
||||
*/
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const args = process.argv.slice(2);
|
||||
const dir = args.includes('--dir') ? args[args.indexOf('--dir') + 1] : 'tiktok-marketing';
|
||||
const filePath = path.join(dir, 'competitor-research.json');
|
||||
|
||||
function loadData() {
|
||||
if (!fs.existsSync(filePath)) {
|
||||
return {
|
||||
researchDate: '',
|
||||
competitors: [],
|
||||
nicheInsights: { trendingSounds: [], commonFormats: [], gapOpportunities: '', avoidPatterns: '' }
|
||||
};
|
||||
}
|
||||
return JSON.parse(fs.readFileSync(filePath, 'utf-8'));
|
||||
}
|
||||
|
||||
function saveData(data) {
|
||||
fs.writeFileSync(filePath, JSON.stringify(data, null, 2));
|
||||
}
|
||||
|
||||
if (args.includes('--summary')) {
|
||||
const data = loadData();
|
||||
if (data.competitors.length === 0) {
|
||||
console.log('No competitor research yet. Use the browser to research competitors first.');
|
||||
process.exit(0);
|
||||
}
|
||||
console.log(`📊 Competitor Research (${data.researchDate})\n`);
|
||||
console.log(`Found ${data.competitors.length} competitors:\n`);
|
||||
data.competitors.forEach(c => {
|
||||
console.log(` ${c.name} (${c.tiktokHandle || 'no handle'})`);
|
||||
console.log(` Followers: ${c.followers || '?'} | Avg views: ${c.avgViews || '?'}`);
|
||||
if (c.bestVideo) console.log(` Best: ${c.bestVideo.views} views — "${c.bestVideo.hook}"`);
|
||||
if (c.strengths) console.log(` Strengths: ${c.strengths}`);
|
||||
if (c.weaknesses) console.log(` Weaknesses: ${c.weaknesses}`);
|
||||
console.log('');
|
||||
});
|
||||
if (data.nicheInsights?.gapOpportunities) {
|
||||
console.log(`💡 Gap opportunities: ${data.nicheInsights.gapOpportunities}`);
|
||||
}
|
||||
if (data.nicheInsights?.avoidPatterns) {
|
||||
console.log(`⚠️ Avoid: ${data.nicheInsights.avoidPatterns}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (args.includes('--add-competitor')) {
|
||||
const idx = args.indexOf('--add-competitor');
|
||||
const json = args[idx + 1];
|
||||
try {
|
||||
const competitor = JSON.parse(json);
|
||||
const data = loadData();
|
||||
data.competitors.push(competitor);
|
||||
data.researchDate = new Date().toISOString().split('T')[0];
|
||||
saveData(data);
|
||||
console.log(`✅ Added competitor: ${competitor.name}`);
|
||||
} catch (e) {
|
||||
console.error('Invalid JSON for competitor:', e.message);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
if (args.includes('--gaps')) {
|
||||
const data = loadData();
|
||||
if (!data.nicheInsights) {
|
||||
console.log('No niche insights yet.');
|
||||
process.exit(0);
|
||||
}
|
||||
console.log('Gap Analysis:\n');
|
||||
console.log(` Opportunities: ${data.nicheInsights.gapOpportunities || 'None recorded'}`);
|
||||
console.log(` Avoid: ${data.nicheInsights.avoidPatterns || 'None recorded'}`);
|
||||
console.log(` Common formats: ${(data.nicheInsights.commonFormats || []).join(', ') || 'None recorded'}`);
|
||||
console.log(` Trending sounds: ${(data.nicheInsights.trendingSounds || []).join(', ') || 'None recorded'}`);
|
||||
}
|
||||
562
scripts/daily-report.js
Normal file
562
scripts/daily-report.js
Normal file
@@ -0,0 +1,562 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Daily Marketing Report
|
||||
*
|
||||
* Cross-references TikTok post analytics (via Postiz) with RevenueCat conversions
|
||||
* to identify which hooks drive views AND revenue.
|
||||
*
|
||||
* Data sources:
|
||||
* 1. Postiz API → per-post TikTok analytics (views, likes, comments, shares)
|
||||
* 2. Postiz API → platform-level stats (followers, total views) for delta tracking
|
||||
* 3. RevenueCat API (optional) → trials, conversions, revenue
|
||||
*
|
||||
* The diagnostic framework:
|
||||
* - High views + High conversions → SCALE (make variations of winning hooks)
|
||||
* - High views + Low conversions → FIX CTA (hook works, downstream is broken)
|
||||
* - Low views + High conversions → FIX HOOKS (content converts, needs more eyeballs)
|
||||
* - Low views + Low conversions → FULL RESET (try radically different approach)
|
||||
*
|
||||
* Usage: node daily-report.js --config <config.json> [--days 3]
|
||||
* Output: tiktok-marketing/reports/YYYY-MM-DD.md
|
||||
*/
|
||||
|
||||
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');
|
||||
|
||||
if (!configPath) {
|
||||
console.error('Usage: node daily-report.js --config <config.json> [--days 3]');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const config = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
|
||||
const baseDir = path.dirname(configPath);
|
||||
const POSTIZ_URL = 'https://api.postiz.com/public/v1';
|
||||
|
||||
async function postizAPI(endpoint) {
|
||||
const res = await fetch(`${POSTIZ_URL}${endpoint}`, {
|
||||
headers: { 'Authorization': config.postiz.apiKey }
|
||||
});
|
||||
return res.json();
|
||||
}
|
||||
|
||||
async function sleep(ms) { return new Promise(r => setTimeout(r, ms)); }
|
||||
|
||||
// RevenueCat API (if configured)
|
||||
async function getRevenueCatMetrics(startDate, endDate) {
|
||||
if (!config.revenuecat?.enabled || !config.revenuecat?.v2SecretKey) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const RC_URL = 'https://api.revenuecat.com/v2';
|
||||
const headers = {
|
||||
'Authorization': `Bearer ${config.revenuecat.v2SecretKey}`,
|
||||
'Content-Type': 'application/json'
|
||||
};
|
||||
|
||||
try {
|
||||
// Get overview metrics
|
||||
const overviewRes = await fetch(`${RC_URL}/projects/${config.revenuecat.projectId}/metrics/overview`, {
|
||||
headers
|
||||
});
|
||||
const overview = await overviewRes.json();
|
||||
|
||||
// Get recent transactions for conversion attribution
|
||||
const txRes = await fetch(`${RC_URL}/projects/${config.revenuecat.projectId}/transactions?start_from=${startDate.toISOString()}&limit=100`, {
|
||||
headers
|
||||
});
|
||||
const transactions = await txRes.json();
|
||||
|
||||
// Extract key metrics from overview array
|
||||
const metricsMap = {};
|
||||
if (overview.metrics) {
|
||||
overview.metrics.forEach(m => { metricsMap[m.id] = m.value; });
|
||||
}
|
||||
|
||||
return {
|
||||
overview,
|
||||
transactions: transactions.items || [],
|
||||
mrr: metricsMap.mrr || 0,
|
||||
activeTrials: metricsMap.active_trials || 0,
|
||||
activeSubscribers: metricsMap.active_subscriptions || 0,
|
||||
activeUsers: metricsMap.active_users || 0,
|
||||
newCustomers: metricsMap.new_customers || 0,
|
||||
revenue: metricsMap.revenue || 0
|
||||
};
|
||||
} catch (e) {
|
||||
console.log(` ⚠️ RevenueCat API error: ${e.message}`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Load previous day's snapshot for delta tracking
|
||||
function loadPreviousSnapshot() {
|
||||
const snapshotPath = path.join(baseDir, 'analytics-snapshot.json');
|
||||
if (fs.existsSync(snapshotPath)) {
|
||||
return JSON.parse(fs.readFileSync(snapshotPath, 'utf-8'));
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// Load previous platform stats for delta tracking
|
||||
function loadPreviousPlatformStats() {
|
||||
const statsPath = path.join(baseDir, 'platform-stats.json');
|
||||
if (fs.existsSync(statsPath)) {
|
||||
return JSON.parse(fs.readFileSync(statsPath, 'utf-8'));
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function savePlatformStats(stats) {
|
||||
const statsPath = path.join(baseDir, 'platform-stats.json');
|
||||
fs.writeFileSync(statsPath, JSON.stringify(stats, null, 2));
|
||||
}
|
||||
|
||||
(async () => {
|
||||
const now = new Date();
|
||||
const startDate = new Date(now - days * 86400000);
|
||||
const dateStr = now.toISOString().slice(0, 10);
|
||||
|
||||
console.log(`📊 Daily Report — ${dateStr} (last ${days} days)\n`);
|
||||
|
||||
// ==========================================
|
||||
// 1. POSTIZ: Per-post analytics
|
||||
// ==========================================
|
||||
const postsData = await postizAPI(`/posts?startDate=${startDate.toISOString()}&endDate=${now.toISOString()}`);
|
||||
let posts = (postsData.posts || []).filter(p =>
|
||||
p.integration?.providerIdentifier === 'tiktok' &&
|
||||
p.releaseId && p.releaseId !== 'missing'
|
||||
);
|
||||
posts.sort((a, b) => new Date(b.publishDate) - new Date(a.publishDate));
|
||||
|
||||
console.log(` 📱 Found ${posts.length} connected TikTok posts\n`);
|
||||
|
||||
const postResults = [];
|
||||
for (const post of posts) {
|
||||
const analytics = await postizAPI(`/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;
|
||||
});
|
||||
}
|
||||
postResults.push({
|
||||
id: post.id,
|
||||
date: post.publishDate?.slice(0, 10),
|
||||
hook: (post.content || '').substring(0, 70),
|
||||
app: post.integration?.name,
|
||||
views: metrics.views || 0,
|
||||
likes: metrics.likes || 0,
|
||||
comments: metrics.comments || 0,
|
||||
shares: metrics.shares || 0
|
||||
});
|
||||
await sleep(300);
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// 2. POSTIZ: Platform-level stats (delta tracking)
|
||||
// ==========================================
|
||||
const platformStats = {};
|
||||
for (const [platform, intId] of Object.entries(config.postiz?.integrationIds || {})) {
|
||||
const stats = await postizAPI(`/analytics/${intId}`);
|
||||
if (Array.isArray(stats)) {
|
||||
platformStats[platform] = {};
|
||||
stats.forEach(m => {
|
||||
const latest = m.data?.[m.data.length - 1];
|
||||
platformStats[platform][m.label] = parseInt(latest?.total) || 0;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const prevPlatformStats = loadPreviousPlatformStats();
|
||||
savePlatformStats({ date: dateStr, stats: platformStats });
|
||||
|
||||
// ==========================================
|
||||
// 3. REVENUECAT: Conversion metrics (optional)
|
||||
// ==========================================
|
||||
let rcMetrics = null;
|
||||
let rcPrevMetrics = null;
|
||||
|
||||
if (config.revenuecat?.enabled) {
|
||||
console.log(` 💰 Fetching RevenueCat metrics...`);
|
||||
rcMetrics = await getRevenueCatMetrics(startDate, now);
|
||||
|
||||
// Load previous RC snapshot for deltas
|
||||
const rcSnapshotPath = path.join(baseDir, 'rc-snapshot.json');
|
||||
if (fs.existsSync(rcSnapshotPath)) {
|
||||
rcPrevMetrics = JSON.parse(fs.readFileSync(rcSnapshotPath, 'utf-8'));
|
||||
}
|
||||
if (rcMetrics) {
|
||||
fs.writeFileSync(rcSnapshotPath, JSON.stringify({ date: dateStr, ...rcMetrics }, null, 2));
|
||||
}
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// 4. GENERATE REPORT
|
||||
// ==========================================
|
||||
let report = `# Daily Marketing Report — ${dateStr}\n\n`;
|
||||
|
||||
// Per-app breakdown
|
||||
const apps = [...new Set(postResults.map(p => p.app))];
|
||||
|
||||
for (const app of apps) {
|
||||
const appPosts = postResults.filter(p => p.app === app);
|
||||
appPosts.sort((a, b) => b.views - a.views);
|
||||
|
||||
report += `## ${app}\n\n`;
|
||||
report += `| Date | Hook | Views | Likes | Comments | Shares |\n`;
|
||||
report += `|------|------|------:|------:|---------:|-------:|\n`;
|
||||
|
||||
for (const p of appPosts) {
|
||||
const viewStr = p.views > 1000 ? `${(p.views / 1000).toFixed(1)}K` : `${p.views}`;
|
||||
report += `| ${p.date} | ${p.hook.substring(0, 45)}... | ${viewStr} | ${p.likes} | ${p.comments} | ${p.shares} |\n`;
|
||||
}
|
||||
|
||||
const totalViews = appPosts.reduce((s, p) => s + p.views, 0);
|
||||
const avgViews = appPosts.length > 0 ? Math.round(totalViews / appPosts.length) : 0;
|
||||
report += `\n**Total views:** ${totalViews.toLocaleString()} | **Avg per post:** ${avgViews.toLocaleString()}\n\n`;
|
||||
}
|
||||
|
||||
// Platform deltas
|
||||
if (prevPlatformStats) {
|
||||
report += `## Platform Growth (since last report)\n\n`;
|
||||
for (const [platform, stats] of Object.entries(platformStats)) {
|
||||
const prev = prevPlatformStats.stats?.[platform];
|
||||
if (prev) {
|
||||
const followerDelta = (stats.Followers || 0) - (prev.Followers || 0);
|
||||
const viewDelta = (stats.Views || 0) - (prev.Views || 0);
|
||||
report += `**${platform}:** +${followerDelta} followers, +${viewDelta.toLocaleString()} views\n`;
|
||||
} else {
|
||||
report += `**${platform}:** ${stats.Followers || 0} followers, ${(stats.Views || 0).toLocaleString()} total views\n`;
|
||||
}
|
||||
}
|
||||
report += '\n';
|
||||
}
|
||||
|
||||
// RevenueCat section
|
||||
if (rcMetrics) {
|
||||
report += `## Conversions (RevenueCat)\n\n`;
|
||||
report += `- **MRR:** $${rcMetrics.mrr}\n`;
|
||||
report += `- **Active subscribers:** ${rcMetrics.activeSubscribers}\n`;
|
||||
report += `- **Active trials:** ${rcMetrics.activeTrials}\n`;
|
||||
report += `- **Active users (28d):** ${rcMetrics.activeUsers}\n`;
|
||||
report += `- **New customers (28d):** ${rcMetrics.newCustomers}\n`;
|
||||
report += `- **Revenue (28d):** $${rcMetrics.revenue}\n`;
|
||||
|
||||
if (rcPrevMetrics) {
|
||||
const mrrDelta = rcMetrics.mrr - (rcPrevMetrics.mrr || 0);
|
||||
const subDelta = rcMetrics.activeSubscribers - (rcPrevMetrics.activeSubscribers || 0);
|
||||
const trialDelta = rcMetrics.activeTrials - (rcPrevMetrics.activeTrials || 0);
|
||||
const userDelta = rcMetrics.activeUsers - (rcPrevMetrics.activeUsers || 0);
|
||||
const customerDelta = rcMetrics.newCustomers - (rcPrevMetrics.newCustomers || 0);
|
||||
|
||||
report += `\n**Changes since last report:**\n`;
|
||||
report += `- MRR: ${mrrDelta >= 0 ? '+' : ''}$${mrrDelta}\n`;
|
||||
report += `- Subscribers: ${subDelta >= 0 ? '+' : ''}${subDelta}\n`;
|
||||
report += `- Trials: ${trialDelta >= 0 ? '+' : ''}${trialDelta}\n`;
|
||||
report += `- Active users: ${userDelta >= 0 ? '+' : ''}${userDelta}\n`;
|
||||
report += `- New customers: ${customerDelta >= 0 ? '+' : ''}${customerDelta}\n`;
|
||||
|
||||
// Funnel diagnostic
|
||||
report += `\n**Funnel health:**\n`;
|
||||
if (customerDelta > 10 && subDelta === 0) {
|
||||
report += `- ⚠️ Users are downloading (${customerDelta > 0 ? '+' : ''}${customerDelta} new customers) but nobody is subscribing → **App issue** (onboarding/paywall/pricing)\n`;
|
||||
} else if (customerDelta > 10 && subDelta > 0) {
|
||||
report += `- ✅ Funnel working: +${customerDelta} customers → +${subDelta} subscribers (${((subDelta / customerDelta) * 100).toFixed(1)}% conversion)\n`;
|
||||
} else if (customerDelta <= 5) {
|
||||
report += `- ⚠️ Few new customers (${customerDelta > 0 ? '+' : ''}${customerDelta}) → **Marketing issue** (views not converting to downloads — check App Store page, link in bio)\n`;
|
||||
}
|
||||
if (userDelta > 20 && subDelta === 0) {
|
||||
report += `- 🔴 ${userDelta} active users but zero new subs → Users are trying the app but not paying. Check: Is the paywall too aggressive? Is the free experience too good? Is the value proposition clear?\n`;
|
||||
}
|
||||
}
|
||||
|
||||
// Attribution: compare conversion spikes with post timing
|
||||
if (rcMetrics.transactions?.length > 0) {
|
||||
report += `\n### Conversion Attribution (last ${days} days)\n\n`;
|
||||
report += `Found ${rcMetrics.transactions.length} transactions. Cross-referencing with post timing:\n\n`;
|
||||
|
||||
for (const p of postResults.slice(0, 10)) { // top 10 posts
|
||||
const postDate = new Date(p.date);
|
||||
const windowEnd = new Date(postDate.getTime() + 72 * 3600000);
|
||||
const nearbyTx = rcMetrics.transactions.filter(tx => {
|
||||
const txDate = new Date(tx.purchase_date || tx.created_at);
|
||||
return txDate >= postDate && txDate <= windowEnd;
|
||||
});
|
||||
if (nearbyTx.length > 0) {
|
||||
report += `- "${p.hook.substring(0, 40)}..." (${p.views.toLocaleString()} views) → **${nearbyTx.length} conversions within 72h**\n`;
|
||||
}
|
||||
}
|
||||
}
|
||||
report += '\n';
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// 5. DIAGNOSTIC FRAMEWORK
|
||||
// ==========================================
|
||||
report += `## Diagnosis\n\n`;
|
||||
|
||||
for (const app of apps) {
|
||||
const appPosts = postResults.filter(p => p.app === app);
|
||||
const avgViews = appPosts.length > 0
|
||||
? appPosts.reduce((s, p) => s + p.views, 0) / appPosts.length : 0;
|
||||
|
||||
// Determine conversion quality (if RC available)
|
||||
let conversionGood = false;
|
||||
let hasConversionData = false;
|
||||
let usersGrowing = false;
|
||||
if (rcMetrics && rcPrevMetrics) {
|
||||
hasConversionData = true;
|
||||
const subDelta = rcMetrics.activeSubscribers - (rcPrevMetrics.activeSubscribers || 0);
|
||||
const trialDelta = rcMetrics.activeTrials - (rcPrevMetrics.activeTrials || 0);
|
||||
const userDelta = rcMetrics.activeUsers - (rcPrevMetrics.activeUsers || 0);
|
||||
conversionGood = (subDelta + trialDelta) > 2;
|
||||
usersGrowing = userDelta > 10;
|
||||
}
|
||||
|
||||
const viewsGood = avgViews > 10000;
|
||||
|
||||
report += `### ${app}\n\n`;
|
||||
|
||||
if (viewsGood && (!hasConversionData || conversionGood)) {
|
||||
report += `🟢 **Views good${hasConversionData ? ' + Conversions good' : ''}** → SCALE IT\n`;
|
||||
report += `- Average ${Math.round(avgViews).toLocaleString()} views per post\n`;
|
||||
report += `- Make 3 variations of the top-performing hooks\n`;
|
||||
report += `- Test different posting times for optimization\n`;
|
||||
report += `- Cross-post to Instagram Reels & YouTube Shorts\n`;
|
||||
} else if (viewsGood && hasConversionData && !conversionGood) {
|
||||
report += `🟡 **Views good + Conversions poor** → FIX THE CTA\n`;
|
||||
report += `- People are watching (avg ${Math.round(avgViews).toLocaleString()} views) but not converting\n`;
|
||||
report += `- Try different CTAs on slide 6 (direct vs subtle)\n`;
|
||||
report += `- Check if app landing page matches the slideshow promise\n`;
|
||||
report += `- Test different caption structures\n`;
|
||||
report += `- DO NOT change the hooks — they're working\n`;
|
||||
} else if (!viewsGood && hasConversionData && conversionGood) {
|
||||
report += `🟡 **Views poor + Conversions good** → FIX THE HOOKS\n`;
|
||||
report += `- People who see it convert, but not enough see it (avg ${Math.round(avgViews).toLocaleString()} views)\n`;
|
||||
report += `- Test radically different hook categories\n`;
|
||||
report += `- Try person+conflict, POV, listicle, mistakes formats\n`;
|
||||
report += `- Test different posting times and slide 1 thumbnails\n`;
|
||||
report += `- DO NOT change the CTA — it's converting\n`;
|
||||
} else if (!viewsGood && (!hasConversionData || !conversionGood)) {
|
||||
report += `🔴 **Views poor${hasConversionData ? ' + Conversions poor' : ''}** → NEEDS WORK\n`;
|
||||
report += `- Average ${Math.round(avgViews).toLocaleString()} views per post\n`;
|
||||
report += `- Try radically different format/approach\n`;
|
||||
report += `- Research what's trending in the niche RIGHT NOW\n`;
|
||||
report += `- Consider different target audience angle\n`;
|
||||
report += `- Test new hook categories from scratch\n`;
|
||||
if (!hasConversionData) {
|
||||
report += `- ⚠️ No conversion data — consider connecting RevenueCat for full picture\n`;
|
||||
}
|
||||
}
|
||||
report += '\n';
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// 6. HOOK + CTA PERFORMANCE TRACKING
|
||||
// ==========================================
|
||||
const hookPath = path.join(baseDir, 'hook-performance.json');
|
||||
let hookData = { hooks: [], ctas: [], rules: { doubleDown: [], testing: [], dropped: [] } };
|
||||
if (fs.existsSync(hookPath)) {
|
||||
hookData = JSON.parse(fs.readFileSync(hookPath, 'utf-8'));
|
||||
if (!hookData.ctas) hookData.ctas = [];
|
||||
}
|
||||
|
||||
// Update hook performance with conversion data
|
||||
for (const p of postResults) {
|
||||
// Calculate conversions attributed to this post (72h window)
|
||||
let conversions = 0;
|
||||
if (rcMetrics?.transactions?.length > 0) {
|
||||
const postDate = new Date(p.date);
|
||||
const windowEnd = new Date(postDate.getTime() + 72 * 3600000);
|
||||
conversions = rcMetrics.transactions.filter(tx => {
|
||||
const txDate = new Date(tx.purchase_date || tx.created_at);
|
||||
return txDate >= postDate && txDate <= windowEnd;
|
||||
}).length;
|
||||
}
|
||||
|
||||
const existing = hookData.hooks.find(h => h.postId === p.id);
|
||||
if (existing) {
|
||||
existing.views = p.views;
|
||||
existing.likes = p.likes;
|
||||
existing.conversions = conversions;
|
||||
existing.lastChecked = dateStr;
|
||||
} else {
|
||||
hookData.hooks.push({
|
||||
postId: p.id,
|
||||
text: p.hook,
|
||||
app: p.app,
|
||||
date: p.date,
|
||||
views: p.views,
|
||||
likes: p.likes,
|
||||
comments: p.comments,
|
||||
shares: p.shares,
|
||||
conversions,
|
||||
cta: '', // agent should tag this when creating posts
|
||||
lastChecked: dateStr
|
||||
});
|
||||
}
|
||||
}
|
||||
fs.writeFileSync(hookPath, JSON.stringify(hookData, null, 2));
|
||||
|
||||
// ==========================================
|
||||
// 7. AUTOMATED FUNNEL DIAGNOSIS PER POST
|
||||
// ==========================================
|
||||
report += `## Per-Post Funnel Diagnosis\n\n`;
|
||||
|
||||
const hasRC = rcMetrics && rcPrevMetrics;
|
||||
const allHooks = hookData.hooks.filter(h => h.lastChecked === dateStr);
|
||||
|
||||
if (allHooks.length > 0 && hasRC) {
|
||||
// Sort by views descending
|
||||
const sorted = [...allHooks].sort((a, b) => b.views - a.views);
|
||||
const viewMedian = sorted[Math.floor(sorted.length / 2)]?.views || 1000;
|
||||
|
||||
for (const h of sorted) {
|
||||
const highViews = h.views > viewMedian && h.views > 5000;
|
||||
const hasConversions = h.conversions > 0;
|
||||
|
||||
report += `**"${h.text.substring(0, 55)}..."** — ${h.views.toLocaleString()} views, ${h.conversions} conversions\n`;
|
||||
|
||||
if (highViews && hasConversions) {
|
||||
report += ` 🟢 Hook + CTA both working → SCALE this hook, keep the CTA\n`;
|
||||
} else if (highViews && !hasConversions) {
|
||||
report += ` 🟡 High views but no conversions → Hook is good, CTA needs changing. Try a different slide 6 CTA.\n`;
|
||||
} else if (!highViews && hasConversions) {
|
||||
report += ` 🟡 Low views but people who saw it converted → CTA is great, hook needs work. Try a stronger hook with the same CTA.\n`;
|
||||
} else {
|
||||
report += ` 🔴 Low views + no conversions → Drop this hook and CTA combination\n`;
|
||||
}
|
||||
report += '\n';
|
||||
}
|
||||
|
||||
// Check for systemic app issues
|
||||
const totalRecentViews = sorted.reduce((s, h) => s + h.views, 0);
|
||||
const totalConversions = sorted.reduce((s, h) => s + h.conversions, 0);
|
||||
const subDelta = rcMetrics.activeSubscribers - (rcPrevMetrics.activeSubscribers || 0);
|
||||
const customerDelta = rcMetrics.newCustomers - (rcPrevMetrics.newCustomers || 0);
|
||||
|
||||
if (totalRecentViews > 50000 && customerDelta > 10 && subDelta <= 0) {
|
||||
report += `### 🔴 APP ISSUE DETECTED\n\n`;
|
||||
report += `Views are high (${totalRecentViews.toLocaleString()}) and people are downloading (+${customerDelta} new customers), but nobody is paying (${subDelta >= 0 ? '+' : ''}${subDelta} subscribers).\n`;
|
||||
report += `This is NOT a marketing problem — the content is working. The app onboarding, paywall, or pricing needs attention.\n`;
|
||||
report += `- Is the paywall shown at the right time?\n`;
|
||||
report += `- Is the free experience too generous?\n`;
|
||||
report += `- Is the value proposition clear before the paywall?\n`;
|
||||
report += `- Does the onboarding guide users to the "aha moment"?\n\n`;
|
||||
} else if (totalRecentViews > 50000 && customerDelta <= 3) {
|
||||
report += `### 🟡 CTA ISSUE DETECTED\n\n`;
|
||||
report += `Views are high (${totalRecentViews.toLocaleString()}) but very few people are downloading (+${customerDelta} new customers).\n`;
|
||||
report += `The hooks are working but the CTAs aren't driving action. Rotate to a different CTA style.\n\n`;
|
||||
}
|
||||
} else if (!hasRC) {
|
||||
report += `⚠️ No RevenueCat data — can only diagnose hooks (views), not CTAs (conversions). Connect RevenueCat for full funnel intelligence.\n\n`;
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// 8. AUTO-GENERATED HOOKS & CTAs
|
||||
// ==========================================
|
||||
report += `## Auto-Generated Recommendations\n\n`;
|
||||
|
||||
// Analyse all historical hooks to find patterns
|
||||
const allHistorical = hookData.hooks.filter(h => h.views > 0);
|
||||
|
||||
for (const app of apps) {
|
||||
const appHooks = allHistorical.filter(h => h.app === app);
|
||||
if (appHooks.length === 0) continue;
|
||||
|
||||
appHooks.sort((a, b) => b.views - a.views);
|
||||
const winners = appHooks.filter(h => h.views >= 10000);
|
||||
const losers = appHooks.filter(h => h.views < 1000);
|
||||
|
||||
report += `### ${app}\n\n`;
|
||||
|
||||
// Identify winning patterns from actual hook text
|
||||
if (winners.length > 0) {
|
||||
report += `**Winning hooks (10K+ views):**\n`;
|
||||
for (const w of winners.slice(0, 5)) {
|
||||
report += `- "${w.text.substring(0, 60)}..." — ${w.views.toLocaleString()} views, ${w.conversions || 0} conversions\n`;
|
||||
}
|
||||
report += '\n';
|
||||
|
||||
// Extract patterns from winners
|
||||
const winnerTexts = winners.map(w => w.text.toLowerCase());
|
||||
const patterns = {
|
||||
personConflict: winnerTexts.filter(t => /\b(showed|told|said|asked)\b/.test(t) && /\b(my |mum|mom|dad|landlord|boyfriend|girlfriend|friend|sister|brother|boss|nan)\b/.test(t)).length,
|
||||
pov: winnerTexts.filter(t => t.startsWith('pov')).length,
|
||||
question: winnerTexts.filter(t => t.includes('?')).length,
|
||||
emotion: winnerTexts.filter(t => /\b(obsessed|can't believe|shocked|never thought|didn't expect)\b/.test(t)).length
|
||||
};
|
||||
|
||||
const topPattern = Object.entries(patterns).sort((a, b) => b[1] - a[1])[0];
|
||||
|
||||
report += `**Strongest pattern:** ${topPattern[0]} (${topPattern[1]}/${winners.length} winners use this)\n\n`;
|
||||
|
||||
// Generate new hooks based on winning patterns
|
||||
report += `**🔥 Generated hooks (based on your winners):**\n`;
|
||||
report += `\n`;
|
||||
report += `The agent should now generate 3-5 NEW hooks that follow the "${topPattern[0]}" pattern, using:\n`;
|
||||
report += `- The app's product description from config\n`;
|
||||
report += `- The winning hook structures above as templates\n`;
|
||||
report += `- Different people/scenarios to keep it fresh\n\n`;
|
||||
|
||||
// Specific generation instructions for the agent
|
||||
report += `**AGENT INSTRUCTION:** Read the winning hooks above. Identify the structure (who + conflict + reveal). `;
|
||||
report += `Generate 3 new hooks that follow the SAME structure but with different people and scenarios. `;
|
||||
report += `Example: if "I showed my landlord..." won, try "I showed my nan...", "I showed my boss...", "My flatmate didn't believe..."\n\n`;
|
||||
}
|
||||
|
||||
if (losers.length > 0) {
|
||||
report += `**Drop these patterns (< 1K views):**\n`;
|
||||
for (const l of losers.slice(0, 3)) {
|
||||
report += `- "${l.text.substring(0, 60)}..." — ${l.views} views\n`;
|
||||
}
|
||||
report += '\n';
|
||||
}
|
||||
|
||||
// CTA recommendations based on conversion data
|
||||
if (hasRC) {
|
||||
const highViewLowConvert = appHooks.filter(h => h.views > 10000 && (h.conversions || 0) === 0);
|
||||
const lowViewHighConvert = appHooks.filter(h => h.views < 5000 && (h.conversions || 0) > 0);
|
||||
|
||||
if (highViewLowConvert.length > 0) {
|
||||
report += `**🔄 CTA rotation needed** — ${highViewLowConvert.length} posts got 10K+ views but zero conversions.\n`;
|
||||
report += `Current CTAs aren't driving downloads. Try rotating through:\n`;
|
||||
report += `- "Download [app] — link in bio"\n`;
|
||||
report += `- "[app] is free to try — link in bio"\n`;
|
||||
report += `- "I used [app] for this — link in bio"\n`;
|
||||
report += `- "Search [app] on the App Store"\n`;
|
||||
report += `- No explicit CTA (just app name visible on slide 6)\n`;
|
||||
report += `Track which CTA each post uses in hook-performance.json to identify what converts.\n\n`;
|
||||
}
|
||||
|
||||
if (lowViewHighConvert.length > 0) {
|
||||
report += `**💎 Hidden gems** — ${lowViewHighConvert.length} posts got low views but high conversions.\n`;
|
||||
report += `The CTA on these posts is working. Reuse that CTA with stronger hooks.\n`;
|
||||
for (const g of lowViewHighConvert) {
|
||||
report += `- "${g.text.substring(0, 50)}..." — ${g.views} views, ${g.conversions} conversions (CTA: ${g.cta || 'unknown'})\n`;
|
||||
}
|
||||
report += '\n';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// 8. SAVE REPORT
|
||||
// ==========================================
|
||||
const reportsDir = path.join(baseDir, 'reports');
|
||||
fs.mkdirSync(reportsDir, { recursive: true });
|
||||
const reportPath = path.join(reportsDir, `${dateStr}.md`);
|
||||
fs.writeFileSync(reportPath, report);
|
||||
console.log(`\n📋 Report saved to ${reportPath}`);
|
||||
console.log('\n' + report);
|
||||
})();
|
||||
231
scripts/generate-slides.js
Normal file
231
scripts/generate-slides.js
Normal file
@@ -0,0 +1,231 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Generate 6 TikTok slideshow images using the user's chosen image generation provider.
|
||||
*
|
||||
* Supported providers:
|
||||
* - openai (gpt-image-1.5 STRONGLY RECOMMENDED — never use gpt-image-1)
|
||||
* - stability (Stable Diffusion via Stability AI API)
|
||||
* - replicate (any model via Replicate API)
|
||||
* - local (user provides pre-made images, skips generation)
|
||||
*
|
||||
* Usage: node generate-slides.js --config <config.json> --output <dir> --prompts <prompts.json>
|
||||
*
|
||||
* prompts.json format:
|
||||
* {
|
||||
* "base": "Shared base prompt for all slides",
|
||||
* "slides": ["Slide 1 additions", "Slide 2 additions", ...6 total]
|
||||
* }
|
||||
*/
|
||||
|
||||
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 outputDir = getArg('output');
|
||||
const promptsPath = getArg('prompts');
|
||||
|
||||
if (!configPath || !outputDir || !promptsPath) {
|
||||
console.error('Usage: node generate-slides.js --config <config.json> --output <dir> --prompts <prompts.json>');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const config = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
|
||||
const prompts = JSON.parse(fs.readFileSync(promptsPath, 'utf-8'));
|
||||
|
||||
if (!prompts.slides || prompts.slides.length !== 6) {
|
||||
console.error('ERROR: prompts.json must have exactly 6 slides');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
fs.mkdirSync(outputDir, { recursive: true });
|
||||
|
||||
const provider = config.imageGen?.provider || 'openai';
|
||||
const model = config.imageGen?.model || 'gpt-image-1.5';
|
||||
const apiKey = config.imageGen?.apiKey;
|
||||
|
||||
if (!apiKey && provider !== 'local') {
|
||||
console.error(`ERROR: No API key found in config.imageGen.apiKey for provider "${provider}"`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Warn if using gpt-image-1 instead of 1.5
|
||||
if (provider === 'openai' && model && !model.includes('1.5')) {
|
||||
console.warn(`\n⚠️ WARNING: You're using "${model}" — this produces noticeably AI-looking images.`);
|
||||
console.warn(` STRONGLY RECOMMENDED: Switch to "gpt-image-1.5" in your config for photorealistic results.`);
|
||||
console.warn(` The quality difference is massive and directly impacts views.\n`);
|
||||
}
|
||||
|
||||
// ─── Provider: OpenAI ───────────────────────────────────────────────
|
||||
async function generateOpenAI(prompt, outPath) {
|
||||
const res = await fetch('https://api.openai.com/v1/images/generations', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${apiKey}`,
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model,
|
||||
prompt,
|
||||
n: 1,
|
||||
size: '1024x1536',
|
||||
quality: 'high'
|
||||
}),
|
||||
signal: global.__abortSignal
|
||||
});
|
||||
const data = await res.json();
|
||||
if (data.error) throw new Error(data.error.message);
|
||||
fs.writeFileSync(outPath, Buffer.from(data.data[0].b64_json, 'base64'));
|
||||
}
|
||||
|
||||
// ─── Provider: Stability AI ─────────────────────────────────────────
|
||||
async function generateStability(prompt, outPath) {
|
||||
const engineId = model || 'stable-diffusion-xl-1024-v1-0';
|
||||
const res = await fetch(`https://api.stability.ai/v1/generation/${engineId}/text-to-image`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${apiKey}`,
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
text_prompts: [{ text: prompt, weight: 1 }],
|
||||
cfg_scale: 7,
|
||||
height: 1536,
|
||||
width: 1024,
|
||||
steps: 30,
|
||||
samples: 1
|
||||
})
|
||||
});
|
||||
const data = await res.json();
|
||||
if (data.message) throw new Error(data.message);
|
||||
fs.writeFileSync(outPath, Buffer.from(data.artifacts[0].base64, 'base64'));
|
||||
}
|
||||
|
||||
// ─── Provider: Replicate ────────────────────────────────────────────
|
||||
async function generateReplicate(prompt, outPath) {
|
||||
const replicateModel = model || 'black-forest-labs/flux-1.1-pro';
|
||||
|
||||
// Create prediction
|
||||
const createRes = await fetch('https://api.replicate.com/v1/predictions', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Token ${apiKey}`,
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: replicateModel,
|
||||
input: {
|
||||
prompt,
|
||||
width: 1024,
|
||||
height: 1536,
|
||||
num_outputs: 1
|
||||
}
|
||||
})
|
||||
});
|
||||
let prediction = await createRes.json();
|
||||
if (prediction.error) throw new Error(prediction.error.detail || prediction.error);
|
||||
|
||||
// Poll for completion
|
||||
while (prediction.status !== 'succeeded' && prediction.status !== 'failed') {
|
||||
await new Promise(r => setTimeout(r, 2000));
|
||||
const pollRes = await fetch(prediction.urls.get, {
|
||||
headers: { 'Authorization': `Token ${apiKey}` }
|
||||
});
|
||||
prediction = await pollRes.json();
|
||||
}
|
||||
if (prediction.status === 'failed') throw new Error(prediction.error || 'Prediction failed');
|
||||
|
||||
// Download image
|
||||
const imageUrl = Array.isArray(prediction.output) ? prediction.output[0] : prediction.output;
|
||||
const imgRes = await fetch(imageUrl);
|
||||
const buf = Buffer.from(await imgRes.arrayBuffer());
|
||||
fs.writeFileSync(outPath, buf);
|
||||
}
|
||||
|
||||
// ─── Provider: Local (skip generation) ──────────────────────────────
|
||||
async function generateLocal(prompt, outPath) {
|
||||
const slideNum = path.basename(outPath).match(/\d+/)?.[0];
|
||||
const localPath = path.join(outputDir, `local_slide${slideNum}.png`);
|
||||
if (fs.existsSync(localPath)) {
|
||||
fs.copyFileSync(localPath, outPath);
|
||||
} else {
|
||||
throw new Error(`Place your image at ${localPath} — local provider skips generation`);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Retry with timeout ─────────────────────────────────────────────
|
||||
async function withRetry(fn, retries = 2, timeoutMs = 120000) {
|
||||
for (let attempt = 0; attempt <= retries; attempt++) {
|
||||
try {
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
||||
// Pass abort signal via global (providers use fetch which supports it)
|
||||
global.__abortSignal = controller.signal;
|
||||
const result = await fn();
|
||||
clearTimeout(timer);
|
||||
return result;
|
||||
} catch (e) {
|
||||
if (attempt < retries) {
|
||||
const isTimeout = e.name === 'AbortError' || e.message?.includes('timeout') || e.message?.includes('abort');
|
||||
console.log(` ⚠️ ${isTimeout ? 'Timeout' : 'Error'}: ${e.message}. Retrying (${attempt + 1}/${retries})...`);
|
||||
await new Promise(r => setTimeout(r, 3000 * (attempt + 1)));
|
||||
} else {
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Router ─────────────────────────────────────────────────────────
|
||||
const providers = {
|
||||
openai: generateOpenAI,
|
||||
stability: generateStability,
|
||||
replicate: generateReplicate,
|
||||
local: generateLocal
|
||||
};
|
||||
|
||||
async function generate(prompt, outPath) {
|
||||
const fn = providers[provider];
|
||||
if (!fn) {
|
||||
console.error(`Unknown provider: "${provider}". Supported: ${Object.keys(providers).join(', ')}`);
|
||||
process.exit(1);
|
||||
}
|
||||
console.log(` Generating ${path.basename(outPath)} [${provider}/${model}]...`);
|
||||
await withRetry(() => fn(prompt, outPath));
|
||||
console.log(` ✅ ${path.basename(outPath)}`);
|
||||
}
|
||||
|
||||
(async () => {
|
||||
console.log(`🎬 Generating 6 slides for ${config.app?.name || 'app'} using ${provider}/${model}\n`);
|
||||
let success = 0;
|
||||
let skipped = 0;
|
||||
for (let i = 0; i < 6; i++) {
|
||||
const outPath = path.join(outputDir, `slide${i + 1}_raw.png`);
|
||||
// Skip if already exists (resume from partial run)
|
||||
if (fs.existsSync(outPath) && fs.statSync(outPath).size > 10000) {
|
||||
console.log(` ⏭ slide${i + 1}_raw.png already exists, skipping`);
|
||||
success++;
|
||||
skipped++;
|
||||
continue;
|
||||
}
|
||||
const fullPrompt = `${prompts.base}\n\n${prompts.slides[i]}`;
|
||||
try {
|
||||
await generate(fullPrompt, outPath);
|
||||
success++;
|
||||
} catch (e) {
|
||||
console.error(` ❌ Slide ${i + 1} failed after retries: ${e.message}`);
|
||||
console.error(` Re-run this script to retry — completed slides will be skipped.`);
|
||||
}
|
||||
}
|
||||
console.log(`\n✨ Generated ${success}/6 slides in ${outputDir}${skipped > 0 ? ` (${skipped} skipped — already existed)` : ''}`);
|
||||
if (success < 6) {
|
||||
console.error(`\n⚠️ ${6 - success} slides failed. Re-run to retry — completed slides are preserved.`);
|
||||
process.exit(1);
|
||||
}
|
||||
})();
|
||||
213
scripts/onboarding.js
Normal file
213
scripts/onboarding.js
Normal file
@@ -0,0 +1,213 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* TikTok App Marketing — Onboarding Config Validator
|
||||
*
|
||||
* The onboarding is CONVERSATIONAL — the agent talks to the user naturally,
|
||||
* not through this script. This script validates the resulting config is complete.
|
||||
*
|
||||
* Usage:
|
||||
* node onboarding.js --validate --config tiktok-marketing/config.json
|
||||
* node onboarding.js --init --dir tiktok-marketing/
|
||||
*
|
||||
* --validate: Check config completeness, show what's missing
|
||||
* --init: Create the directory structure and empty config files
|
||||
*/
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const args = process.argv.slice(2);
|
||||
const configPath = args.includes('--config') ? args[args.indexOf('--config') + 1] : null;
|
||||
const validate = args.includes('--validate');
|
||||
const init = args.includes('--init');
|
||||
const dir = args.includes('--dir') ? args[args.indexOf('--dir') + 1] : 'tiktok-marketing';
|
||||
|
||||
if (init) {
|
||||
// Create directory structure
|
||||
const dirs = [dir, `${dir}/posts`, `${dir}/hooks`];
|
||||
dirs.forEach(d => {
|
||||
if (!fs.existsSync(d)) {
|
||||
fs.mkdirSync(d, { recursive: true });
|
||||
console.log(`📁 Created ${d}/`);
|
||||
}
|
||||
});
|
||||
|
||||
// Empty config template
|
||||
const configTemplate = {
|
||||
app: {
|
||||
name: '',
|
||||
description: '',
|
||||
audience: '',
|
||||
problem: '',
|
||||
differentiator: '',
|
||||
appStoreUrl: '',
|
||||
category: '',
|
||||
isMobileApp: false
|
||||
},
|
||||
imageGen: {
|
||||
provider: '',
|
||||
apiKey: '',
|
||||
model: ''
|
||||
},
|
||||
postiz: {
|
||||
apiKey: '',
|
||||
integrationIds: {
|
||||
tiktok: ''
|
||||
}
|
||||
},
|
||||
revenuecat: {
|
||||
enabled: false,
|
||||
v2SecretKey: '',
|
||||
projectId: ''
|
||||
},
|
||||
posting: {
|
||||
privacyLevel: 'SELF_ONLY',
|
||||
schedule: ['07:30', '16:30', '21:00'],
|
||||
crossPost: []
|
||||
},
|
||||
competitors: `${dir}/competitor-research.json`,
|
||||
strategy: `${dir}/strategy.json`
|
||||
};
|
||||
|
||||
const cfgPath = `${dir}/config.json`;
|
||||
if (!fs.existsSync(cfgPath)) {
|
||||
fs.writeFileSync(cfgPath, JSON.stringify(configTemplate, null, 2));
|
||||
console.log(`📝 Created ${cfgPath}`);
|
||||
}
|
||||
|
||||
// Empty competitor research template
|
||||
const compPath = `${dir}/competitor-research.json`;
|
||||
if (!fs.existsSync(compPath)) {
|
||||
fs.writeFileSync(compPath, JSON.stringify({
|
||||
researchDate: '',
|
||||
competitors: [],
|
||||
nicheInsights: {
|
||||
trendingSounds: [],
|
||||
commonFormats: [],
|
||||
gapOpportunities: '',
|
||||
avoidPatterns: ''
|
||||
}
|
||||
}, null, 2));
|
||||
console.log(`📝 Created ${compPath}`);
|
||||
}
|
||||
|
||||
// Empty strategy template
|
||||
const stratPath = `${dir}/strategy.json`;
|
||||
if (!fs.existsSync(stratPath)) {
|
||||
fs.writeFileSync(stratPath, JSON.stringify({
|
||||
hooks: [],
|
||||
postingSchedule: ['07:30', '16:30', '21:00'],
|
||||
hookCategories: { testing: [], proven: [], dropped: [] },
|
||||
crossPostPlatforms: [],
|
||||
notes: ''
|
||||
}, null, 2));
|
||||
console.log(`📝 Created ${stratPath}`);
|
||||
}
|
||||
|
||||
// Empty hook performance tracker
|
||||
const hookPath = `${dir}/hook-performance.json`;
|
||||
if (!fs.existsSync(hookPath)) {
|
||||
fs.writeFileSync(hookPath, JSON.stringify({
|
||||
hooks: [],
|
||||
rules: { doubleDown: [], testing: [], dropped: [] }
|
||||
}, null, 2));
|
||||
console.log(`📝 Created ${hookPath}`);
|
||||
}
|
||||
|
||||
console.log('\n✅ Directory structure ready. Start the conversational onboarding to fill in config.');
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
if (validate && configPath) {
|
||||
if (!fs.existsSync(configPath)) {
|
||||
console.error(`❌ Config not found: ${configPath}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const config = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
|
||||
const required = [];
|
||||
const optional = [];
|
||||
|
||||
// App profile (required)
|
||||
if (!config.app?.name) required.push('app.name — What is the app called?');
|
||||
if (!config.app?.description) required.push('app.description — What does it do?');
|
||||
if (!config.app?.audience) required.push('app.audience — Who is it for?');
|
||||
if (!config.app?.problem) required.push('app.problem — What problem does it solve?');
|
||||
if (!config.app?.category) required.push('app.category — What category?');
|
||||
|
||||
// Image generation (required)
|
||||
if (!config.imageGen?.provider) required.push('imageGen.provider — Which image tool?');
|
||||
if (config.imageGen?.provider && config.imageGen.provider !== 'local' && !config.imageGen?.apiKey) {
|
||||
required.push('imageGen.apiKey — API key for image generation');
|
||||
}
|
||||
|
||||
// Postiz (required)
|
||||
if (!config.postiz?.apiKey) required.push('postiz.apiKey — Postiz API key');
|
||||
if (!config.postiz?.integrationIds?.tiktok) required.push('postiz.integrationIds.tiktok — TikTok integration ID');
|
||||
|
||||
// Competitor research (important but not blocking)
|
||||
const compPath = config.competitors;
|
||||
if (compPath && fs.existsSync(compPath)) {
|
||||
const comp = JSON.parse(fs.readFileSync(compPath, 'utf-8'));
|
||||
if (!comp.competitors || comp.competitors.length === 0) {
|
||||
optional.push('Competitor research — no competitors analyzed yet (run browser research)');
|
||||
}
|
||||
} else {
|
||||
optional.push('Competitor research — file not created yet');
|
||||
}
|
||||
|
||||
// Strategy
|
||||
const stratPath = config.strategy;
|
||||
if (stratPath && fs.existsSync(stratPath)) {
|
||||
const strat = JSON.parse(fs.readFileSync(stratPath, 'utf-8'));
|
||||
if (!strat.hooks || strat.hooks.length === 0) {
|
||||
optional.push('Content strategy — no hooks planned yet');
|
||||
}
|
||||
} else {
|
||||
optional.push('Content strategy — file not created yet');
|
||||
}
|
||||
|
||||
// RevenueCat (optional)
|
||||
if (config.app?.isMobileApp && !config.revenuecat?.enabled) {
|
||||
optional.push('RevenueCat — mobile app detected but RC not connected (recommended for conversion tracking)');
|
||||
}
|
||||
|
||||
// App Store link
|
||||
if (!config.app?.appStoreUrl) optional.push('App Store / website URL — helpful for competitor research');
|
||||
|
||||
// Results
|
||||
if (required.length === 0) {
|
||||
console.log('✅ Core config complete! Ready to start posting.\n');
|
||||
} else {
|
||||
console.log('❌ Missing required config:\n');
|
||||
required.forEach(r => console.log(` ⬚ ${r}`));
|
||||
console.log('');
|
||||
}
|
||||
|
||||
if (optional.length > 0) {
|
||||
console.log('💡 Recommended (not blocking):\n');
|
||||
optional.forEach(o => console.log(` ○ ${o}`));
|
||||
console.log('');
|
||||
}
|
||||
|
||||
// Summary
|
||||
console.log('📋 Setup Summary:');
|
||||
console.log(` App: ${config.app?.name || '(not set)'}`);
|
||||
console.log(` Category: ${config.app?.category || '(not set)'}`);
|
||||
console.log(` Image Gen: ${config.imageGen?.provider || '(not set)'}${config.imageGen?.model ? ` (${config.imageGen.model})` : ''}`);
|
||||
console.log(` TikTok: ${config.postiz?.integrationIds?.tiktok ? 'Connected' : 'Not connected'}`);
|
||||
|
||||
const crossPost = Object.keys(config.postiz?.integrationIds || {}).filter(k => k !== 'tiktok' && config.postiz.integrationIds[k]);
|
||||
if (crossPost.length > 0) console.log(` Cross-posting: ${crossPost.join(', ')}`);
|
||||
|
||||
if (config.revenuecat?.enabled) console.log(` RevenueCat: Connected`);
|
||||
|
||||
console.log(` Privacy: ${config.posting?.privacyLevel || 'SELF_ONLY'}`);
|
||||
console.log(` Schedule: ${(config.posting?.schedule || []).join(', ')}`);
|
||||
|
||||
process.exit(required.length > 0 ? 1 : 0);
|
||||
} else {
|
||||
console.log('Usage:');
|
||||
console.log(' node onboarding.js --init --dir tiktok-marketing/ Create directory structure');
|
||||
console.log(' node onboarding.js --validate --config config.json Validate config completeness');
|
||||
}
|
||||
116
scripts/post-to-tiktok.js
Normal file
116
scripts/post-to-tiktok.js
Normal file
@@ -0,0 +1,116 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Post a 6-slide TikTok slideshow via Postiz API.
|
||||
*
|
||||
* Usage: node post-to-tiktok.js --config <config.json> --dir <slides-dir> --caption "caption text" --title "post title"
|
||||
*
|
||||
* Uploads slide1.png through slide6.png, then creates a TikTok slideshow post.
|
||||
* Posts as SELF_ONLY (draft) by default — user adds music then publishes.
|
||||
*/
|
||||
|
||||
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 dir = getArg('dir');
|
||||
const caption = getArg('caption');
|
||||
const title = getArg('title') || '';
|
||||
|
||||
if (!configPath || !dir || !caption) {
|
||||
console.error('Usage: node post-to-tiktok.js --config <config.json> --dir <dir> --caption "text" [--title "text"]');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const config = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
|
||||
const BASE_URL = 'https://api.postiz.com/public/v1';
|
||||
|
||||
async function uploadImage(filePath) {
|
||||
const form = new FormData();
|
||||
const blob = new Blob([fs.readFileSync(filePath)], { type: 'image/png' });
|
||||
form.append('file', blob, path.basename(filePath));
|
||||
|
||||
const res = await fetch(`${BASE_URL}/upload`, {
|
||||
method: 'POST',
|
||||
headers: { 'Authorization': config.postiz.apiKey },
|
||||
body: form
|
||||
});
|
||||
return res.json();
|
||||
}
|
||||
|
||||
(async () => {
|
||||
console.log('📤 Uploading slides...');
|
||||
const images = [];
|
||||
for (let i = 1; i <= 6; i++) {
|
||||
const filePath = path.join(dir, `slide${i}.png`);
|
||||
if (!fs.existsSync(filePath)) {
|
||||
console.error(` ❌ Missing: ${filePath}`);
|
||||
process.exit(1);
|
||||
}
|
||||
console.log(` Uploading slide ${i}...`);
|
||||
const resp = await uploadImage(filePath);
|
||||
if (resp.error) {
|
||||
console.error(` ❌ Upload error: ${JSON.stringify(resp.error)}`);
|
||||
process.exit(1);
|
||||
}
|
||||
images.push({ id: resp.id, path: resp.path });
|
||||
console.log(` ✅ ${resp.id}`);
|
||||
// Rate limit buffer
|
||||
if (i < 6) await new Promise(r => setTimeout(r, 1500));
|
||||
}
|
||||
|
||||
console.log('\n📱 Creating TikTok post...');
|
||||
const privacy = config.posting?.privacyLevel || 'SELF_ONLY';
|
||||
|
||||
const postRes = await fetch(`${BASE_URL}/posts`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': config.postiz.apiKey,
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
type: 'now',
|
||||
date: new Date().toISOString(),
|
||||
shortLink: false,
|
||||
tags: [],
|
||||
posts: [{
|
||||
integration: { id: config.postiz.integrationId },
|
||||
value: [{ content: caption, image: images }],
|
||||
settings: {
|
||||
__type: 'tiktok',
|
||||
title: title,
|
||||
privacy_level: privacy,
|
||||
duet: false,
|
||||
stitch: false,
|
||||
comment: true,
|
||||
autoAddMusic: 'no',
|
||||
brand_content_toggle: false,
|
||||
brand_organic_toggle: false,
|
||||
video_made_with_ai: true,
|
||||
content_posting_method: 'UPLOAD'
|
||||
}
|
||||
}]
|
||||
})
|
||||
});
|
||||
|
||||
const result = await postRes.json();
|
||||
console.log('✅ Posted!', JSON.stringify(result));
|
||||
|
||||
// Save metadata
|
||||
const metaPath = path.join(dir, 'meta.json');
|
||||
const meta = {
|
||||
postId: result[0]?.postId,
|
||||
caption,
|
||||
title,
|
||||
privacy,
|
||||
postedAt: new Date().toISOString(),
|
||||
images: images.length
|
||||
};
|
||||
fs.writeFileSync(metaPath, JSON.stringify(meta, null, 2));
|
||||
console.log(`📋 Metadata saved to ${metaPath}`);
|
||||
})();
|
||||
Reference in New Issue
Block a user