Initial commit with translated description

This commit is contained in:
2026-03-29 13:13:54 +08:00
commit 8ad6a822e8
14 changed files with 3037 additions and 0 deletions

192
scripts/add-text-overlay.js Normal file
View 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
View 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}..."`);
}
})();

View 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
View 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
View 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
View 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
View 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}`);
})();