Skill: matttandy855/tiktok-content-pipeline (version 1.0.4)

# TikTok Content Pipeline — OpenClaw Skill Automated TikTok carousel content generation, publishing, scheduling, and analytics-driven optimization for any niche. ## Overview This skill provides a complete content pipeline for TikTok accounts. It handles: - **Content Generation** — AI-generated carousel slides with hooks, CTAs, and keyword overlays - **Smart Scheduling** — Research-backed posting times with account-age-aware frequency - **Publishing** — Post to TikTok via Postiz API (supports drafts, scheduled posts, multi-platform) - **Analytics & Optimization** — Track performance, diagnose issues, auto-implement improvements ## Required Credentials | Credential | Purpose | How to Obtain | |---|---|---| | **Postiz API Key** | Publishing & analytics via Postiz CLI | Sign up at postiz.com → Settings → API Keys | | **TikTok Integration ID** | Links your TikTok account to Postiz | Postiz dashboard → Integrations → Add TikTok | ### Credential Storage (Important) **Recommended:** Set `POSTIZ_API_KEY` as an environment variable (e.g. in `~/.zshrc` or `~/.bashrc`) rather than storing it in config files. The pipeline checks for this env var first. **Alternative:** Store in `accounts/<your-account>/config.json` under `postiz.apiKey`. If using this approach, ensure the file is not committed to version control (add to `.gitignore`). The Integration ID is account-specific and stored in per-account `config.json`. ### Security Notes - This skill executes the `postiz` CLI via shell commands. All arguments are escaped to prevent injection. - The skill writes files only within its own `accounts/` and `output/` directories. - `auto-improve` mode can modify account configs and auto-post — test on a throwback account first. - Run `npm audit` after installing dependencies to check for known vulnerabilities. ## Quick Setup 1. Set your Postiz API key: `export POSTIZ_API_KEY="your-key-here"` 2. Copy `config.example.json` to `accounts/<your-account>/config.json` 3. Fill in your TikTok integration ID 4. Create or adapt a template in `templates/` for your niche 5. Run: `node cli.js create <account> --template <template-name>` See `SETUP.md` for the full setup guide. --- ## How to Use This Skill ### 1. Create a New Account ```bash node cli.js create my-brand --template example-nostalgia ``` This copies the template into `accounts/my-brand/` and sets `createdAt` to now. ### 2. Configure the Account Edit `accounts/my-brand/config.json`: ```json { "account": { "name": "my-brand", "handle": "@mybrand", "niche": "your-niche", "createdAt": "2026-01-15T00:00:00Z" }, "postiz": { "apiKey": "YOUR_KEY", "integrationId": "YOUR_TIKTOK_ID" }, "posting": { "timezone": "Europe/London" } } ``` Or use the CLI: ```bash node cli.js config my-brand --handle @mybrand --integration-id YOUR_ID --api-key YOUR_KEY ``` ### 3. Generate Content ```bash node cli.js generate my-brand --type showcase node cli.js generate my-brand --type showcase --post # Generate and post as draft ``` The generator uses the template's `generator.js` to create carousel slides, applies hooks from config, adds keyword overlays for TikTok SEO, and outputs to `accounts/my-brand/output/`. ### 4. Check the Posting Schedule ```bash node cli.js schedule my-brand # This week's schedule node cli.js schedule my-brand --next # Next optimal posting slot ``` The scheduler automatically adjusts frequency based on account age: - **Days 0-24**: Daily posting (building momentum) - **Days 25-30**: Taper from 7 → 4 posts/week - **Day 31+**: 3-4 posts/week on optimal days ### 5. Run Analytics ```bash node cli.js analytics my-brand --days 7 node cli.js analytics my-brand --days 7 --auto-improve # Auto-implement fixes ``` --- ## Research-Backed Viral Mechanics These findings are baked into the framework's scheduling, optimization, and content scoring: ### Algorithm Priority Signals 1. **Watch time & completion rate** — most critical signal. 80%+ completion = 5x reach. 2. **First 3-second hook** — determines whether content gets distributed at all. 3. **Shares** — strongest engagement signal for virality. 4. **Saves** — growing importance. 15%+ save-to-view = high-value content. 5. **Comment engagement** — quality conversations > generic comments. ### Posting Strategy - **3-4 posts/week** is optimal for established accounts (NOT 3/day — that hurts reach) - **Best days**: Wednesday, Tuesday, Thursday - **Best times**: Tue 5pm, Wed 2-5pm, Thu 3-5pm - **New accounts**: Post daily for first 30 days to build momentum - **Max 1 post/day** for established accounts (2/day max for new accounts) ### Carousel Advantages - **3x more saves** than standard video - **40% longer dwell time** when users engage - **Lower production barrier** — slides are easier to produce than video - **5-7 slides optimal** for maintaining attention - **First slide is everything** — must hook immediately ### Hook Patterns That Work | Type | Example | Avg Engagement | |------|---------|---------------| | Contradiction | "Everyone thinks X, but actually..." | 9%+ | | Challenge | "If you used X, you had no skill 😂" | 11%+ | | Gatekeeping | "Only real ones remember..." | 8%+ | | POV | "POV: You just discovered..." | 7%+ | | Nostalgia | "Remember this? 🔥" | 6%+ | | Question | "Would you do this? Yes or no 👇" | 8%+ | ### Content Scoring The ViralOptimizer scores content before posting (0-100): - **Hook quality** (40% weight) — length, power words, emoji, question format - **Structure** (30% weight) — slide count, audio, format - **Engagement potential** (30% weight) — CTA presence, opinion-split, hashtag count Verdicts: - 80+ = 🔥 HIGH VIRAL POTENTIAL — Post immediately - 65+ = ✅ GOOD — Ready to post - 50+ = ⚠️ DECENT — Consider optimizing - 35+ = 🔧 NEEDS WORK — Apply suggestions - <35 = ❌ LOW POTENTIAL — Rethink approach --- ## Diagnostic Matrix When analyzing post performance, use this matrix to decide what to fix: | Views | Engagement | Diagnosis | Action | |-------|-----------|-----------|--------| | High (>1000) | High (>5%) | **SCALE** | Create 3 variations of this content. Test same hook with different visuals. | | High (>1000) | Low (<3%) | **FIX CTA** | Hook is working — add stronger call-to-action. Add opinion-split or challenge. | | Low (<500) | High saves (>10%) | **FIX HOOK** | Content converts — needs better opening hook. Test trending audio. Stronger first-slide text. | | Low (<500) | Low (<3%) | **FULL RESET** | Try radically different format. Research top creators in niche. Test different posting time. | ### Key Thresholds | Metric | Target | Viral | Poor | |--------|--------|-------|------| | Completion rate | 80% | 90% | 40% | | Save-to-view ratio | 15% | 25% | 3% | | Share rate | 8% | 15% | 2% | | Comment rate | 5% | 10% | 1% | | Profile visit rate | 12% | 20% | 3% | | Follower conversion | 8% | 15% | 2% | --- ## Creating a Custom Template Templates define the content types, hooks, hashtags, and generation logic for a niche. ### Template Structure ``` templates/your-niche/ ├── config.json # Content types, hooks, hashtags, settings ├── generator.js # Content generation logic (extends ContentGenerator) └── README.md # Template documentation ``` ### config.json Required Fields ```json { "account": { "template": "your-niche", "niche": "Your Niche" }, "content": { "types": { "your-content-type": { "description": "...", "slides": 6 } }, "hashtagSets": { "default": ["#yourniche", "#fyp"] } }, "hooks": { "your-content-type": ["Hook text 1", "Hook with {placeholder} 2"] }, "posting": { "timezone": "Europe/London" } } ``` ### generator.js Pattern ```javascript const ContentGenerator = require('../../core/ContentGenerator'); class YourNicheGenerator extends ContentGenerator { getSupportedTypes() { return Object.keys(this.config.content.types); } async _generateContent(contentType, options, outputDir) { this._ensureOutputDir(outputDir); const hook = this.getSlide1Hook(contentType, options); // Generate slides using sharp/canvas/ImageMagick // Return { slides: [...paths], hook: hook.text, caption: '...' } } } module.exports = YourNicheGenerator; ``` --- ## Architecture ``` tiktok-content-pipeline/ ├── cli.js # Universal CLI ├── core/ │ ├── ContentGenerator.js # Abstract base for content generation │ ├── Publisher.js # Postiz API integration │ ├── PostingScheduler.js # Smart scheduling engine │ ├── ViralOptimizer.js # Content scoring & optimization │ └── AnalyticsEngine.js # Performance tracking & insights ├── accounts/ # Per-account configs & output (created at runtime) └── templates/ # Niche templates └── example-nostalgia/ # Example template to fork ``` ### Core Components - **ContentGenerator** — Abstract base class. Subclass it per niche. Handles hooks, question hooks, keyword overlays, placeholder replacement. - **Publisher** — Posts to TikTok (and other platforms) via Postiz CLI. Handles media upload, scheduling, draft mode, and post metadata saving. - **PostingScheduler** — Account-age-aware scheduling. Knows optimal days/times from research. Supports weekly schedule generation and overdue detection. - **ViralOptimizer** — Pre-post content scoring. Post-performance diagnostic matrix. Hook pattern library with engagement-weighted selection. - **AnalyticsEngine** — Pulls metrics from Postiz API. Generates reports with alerts, opportunities, and auto-implementable actions.

path: README.md
content: # TikTok Content Pipeline

A complete, research-backed TikTok content automation skill for OpenClaw. Generate carousel content, publish via Postiz, schedule with account-age-aware timing, and optimize based on real analytics data — for any niche.

## Features

- **Carousel content generation** — AI-powered slide creation with hooks, CTAs, and SEO keyword overlays
- **Smart scheduling** — Research-backed optimal posting times (Tue-Thu 2-5pm) with automatic frequency adjustment based on account age
- **Multi-account management** — Run multiple TikTok accounts from one CLI
- **Viral content scoring** — Pre-post content analysis scores hook quality, structure, and engagement potential (0-100)
- **Performance diagnostics** — Automatic diagnosis matrix: high views + low engagement = fix CTA, low views + high saves = fix hook, etc.
- **Analytics engine** — Pull metrics from Postiz, generate reports, auto-implement optimizations
- **Template system** — Create reusable content templates for any niche (gaming, property, fitness, crypto, food, etc.)
- **Research-backed defaults** — All thresholds, timing, and strategies sourced from 2025-2026 TikTok algorithm research

## Quick Start

1. **Install**: `npm install` in the skill directory
2. **Configure**: Copy `config.example.json` → `accounts/your-brand/config.json`, add your Postiz API key and TikTok integration ID
3. **Create account**: `node cli.js create your-brand --template example-nostalgia`
4. **Generate & post**: `node cli.js generate your-brand --type showcase --post`

## Full Guide

See [SETUP.md](SETUP.md) for detailed setup, custom template creation, and automation via cron.

See [SKILL.md](SKILL.md) for the complete agent reference — viral mechanics, diagnostic matrix, threshold tables, and architecture.


path: SETUP.md
content: # Setup Guide

## Prerequisites

- **Node.js** v18+ (`node --version`)
- **Postiz account** — [postiz.com](https://postiz.com) (self-hosted or cloud)
- **Postiz CLI** — `npm install -g postiz-cli`
- **TikTok account** connected to Postiz as an integration
- **ImageMagick** (optional, for keyword overlays) — `brew install imagemagick`

## Install

```bash
cd /path/to/tiktok-content-pipeline
npm install
```

Dependencies: `canvas`, `cheerio`, `sharp` (for image generation).

## Configure Your First Account

### 1. Copy the example config

```bash
cp config.example.json accounts/my-brand/config.json
```

### 2. Edit `accounts/my-brand/config.json`

```json
{
  "account": {
    "name": "my-brand",
    "handle": "@mytiktok",
    "niche": "gaming-nostalgia",
    "createdAt": "2026-02-01T00:00:00Z"
  },
  "postiz": {
    "apiKey": "your-postiz-api-key",
    "integrationId": "your-tiktok-integration-id"
  },
  "posting": {
    "timezone": "Europe/London"
  }
}
```

**Where to find your Postiz integration ID:**
1. Log into Postiz → Settings → Integrations
2. Find your TikTok integration
3. Copy the integration ID from the URL or settings panel

**Account `createdAt`:** Set this to when your TikTok account was created. The scheduler uses this to determine posting frequency (daily for new accounts, 3-4/week for established ones).

### 3. Create from template

```bash
node cli.js create my-brand --template example-nostalgia
```

## Create a Custom Template

### 1. Create the template directory

```bash
mkdir -p templates/your-niche
```

### 2. Create `templates/your-niche/config.json`

Define your content types, hooks, hashtags. See `templates/example-nostalgia/config.json` for the full structure.

Key sections:
- `content.types` — What kinds of content you produce (each gets a `--type` flag in the CLI)
- `hooks` — Array of hook texts per content type. Use `{placeholder}` for dynamic values.
- `questionHooks` — Optional question-format hooks that drive comment engagement (50% chance of being used on slide 1)
- `content.hashtagSets` — Hashtags per content type
- `searchKeywords` — Keywords overlaid on slides for TikTok SEO
- `ctaSlides` — Call-to-action text for final slides

### 3. Create `templates/your-niche/generator.js`

Extend `ContentGenerator` and implement `getSupportedTypes()` and `_generateContent()`.

```javascript
const ContentGenerator = require('../../core/ContentGenerator');
const sharp = require('sharp');
const path = require('path');

class YourNicheGenerator extends ContentGenerator {
  getSupportedTypes() {
    return Object.keys(this.config.content.types);
  }

  async _generateContent(contentType, options, outputDir) {
    this._ensureOutputDir(outputDir);

    // Get hook for slide 1
    const slide1Hook = this.getSlide1Hook(contentType, options);

    // Generate your slides here using sharp, canvas, or ImageMagick
    const slides = [];
    // ... your slide generation logic ...

    // Build caption
    const hookText = this.generateHook(contentType, options);
    const hashtags = this.getHashtags(contentType);
    const caption = this.buildCaption(hookText, slide1Hook) + '\n\n' + hashtags.join(' ');

    return {
      slides,
      hook: hookText,
      caption,
      outputDir
    };
  }
}

module.exports = YourNicheGenerator;
```

## Run Daily Generation

### Manual

```bash
# Generate content
node cli.js generate my-brand --type showcase

# Generate and auto-post as draft
node cli.js generate my-brand --type showcase --post

# Check schedule
node cli.js schedule my-brand --next
```

### Automated (cron)

```bash
# Add to crontab (runs daily at 2pm)
0 14 * * * cd /path/to/tiktok-content-pipeline && node cli.js generate my-brand --type showcase --post
```

### Analytics

```bash
# Weekly report
node cli.js analytics my-brand --days 7

# Auto-optimize based on results
node cli.js analytics my-brand --days 7 --auto-improve
```

## Multi-Account Setup

Run multiple accounts from one install:

```bash
node cli.js create brand-a --template example-nostalgia
node cli.js create brand-b --template your-custom-template
node cli.js list  # See all accounts
```

Each account gets its own config, output directory, and analytics.


path: cli.js
content: #!/usr/bin/env node
/**
 * Content Pipeline Framework - Universal CLI
 * 
 * Manage multiple content creation accounts from a single interface.
 * Bootstrap new accounts, generate content, publish, and analyze performance.
 * 
 * Usage:
 *   ./cli.js create <account> --template <template>
 *   ./cli.js generate <account> --type <type> [--post]
 *   ./cli.js analytics <account> [--auto-improve]
 *   ./cli.js schedule <account> --batch <count>
 */

const fs = require('fs');
const path = require('path');
const { execSync } = require('child_process');

// Framework core components
const ContentGenerator = require('./core/ContentGenerator');
const Publisher = require('./core/Publisher');
const AnalyticsEngine = require('./core/AnalyticsEngine');
const PostingScheduler = require('./core/PostingScheduler');

class ContentPipelineCLI {
  constructor() {
    this.frameworkRoot = __dirname;
    this.accountsDir = path.join(this.frameworkRoot, 'accounts');
    this.templatesDir = path.join(this.frameworkRoot, 'templates');
    
    // Ensure directories exist
    [this.accountsDir, this.templatesDir].forEach(dir => {
      if (!fs.existsSync(dir)) {
        fs.mkdirSync(dir, { recursive: true });
      }
    });
  }

  async run() {
    const args = process.argv.slice(2);
    const command = args[0];
    const account = args[1];

    switch (command) {
      case 'create':
        await this.createAccount(account, this._getArgs(args));
        break;
      case 'generate':
        await this.generateContent(account, this._getArgs(args));
        break;
      case 'post':
        await this.postContent(account, this._getArgs(args));
        break;
      case 'schedule':
        await this.showSchedule(account, this._getArgs(args));
        break;
      case 'analytics':
        await this.runAnalytics(account, this._getArgs(args));
        break;
      case 'config':
        await this.configureAccount(account, this._getArgs(args));
        break;
      case 'list':
        await this.listAccounts();
        break;
      case 'help':
        this.showHelp();
        break;
      default:
        console.error(`Unknown command: ${command}`);
        this.showHelp();
        process.exit(1);
    }
  }

  /**
   * Create new account from template
   */
  async createAccount(accountName, args) {
    const template = args.template;
    if (!template) {
      console.error('❌ Template required: --template <template-name>');
      return;
    }

    const templateDir = path.join(this.templatesDir, template);
    if (!fs.existsSync(templateDir)) {
      console.error(`❌ Template '${template}' not found in ${this.templatesDir}`);
      return;
    }

    const accountDir = path.join(this.accountsDir, accountName);
    if (fs.existsSync(accountDir)) {
      console.error(`❌ Account '${accountName}' already exists`);
      return;
    }

    console.log(`🚀 Creating account '${accountName}' from template '${template}'...`);

    // Copy template to account directory
    this._copyDirectory(templateDir, accountDir);

    // Update account-specific config
    const configPath = path.join(accountDir, 'config.json');
    if (fs.existsSync(configPath)) {
      const config = JSON.parse(fs.readFileSync(configPath, 'utf8'));
      config.account = {
        ...config.account,
        name: accountName,
        template,
        createdAt: new Date().toISOString()
      };
      fs.writeFileSync(configPath, JSON.stringify(config, null, 2));
    }

    console.log(`✅ Account '${accountName}' created successfully!`);
    console.log(`📁 Location: ${accountDir}`);
    console.log(`⚙️  Configure: ./cli.js config ${accountName} --handle @${accountName}`);
  }

  /**
   * Generate content for account
   */
  async generateContent(accountName, args) {
    const accountDir = this._getAccountDir(accountName);
    const config = this._loadAccountConfig(accountDir);
    
    // Load account-specific generator
    const GeneratorClass = this._loadGenerator(config.account.template);
    const generator = new GeneratorClass(config);

    const contentType = args.type;
    if (!contentType) {
      console.error('❌ Content type required: --type <type>');
      console.log('Available types:', generator.getSupportedTypes().join(', '));
      return;
    }

    // Prepare output directory
    const timestamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19);
    const outputDir = path.join(accountDir, 'output', `${timestamp}-${contentType}`);

    console.log(`🎨 Generating '${contentType}' content for ${accountName}...`);

    try {
      const result = await generator.generate(contentType, args, outputDir);
      console.log(`✅ Content generated successfully!`);
      console.log(`📁 Output: ${outputDir}`);
      console.log(`⏱️  Duration: ${result.duration}ms`);

      // Auto-post if requested
      if (args.post) {
        await this._postGenerated(accountDir, config, result, args);
      }

      return result;
    } catch (error) {
      console.error('❌ Content generation failed:', error.message);
      process.exit(1);
    }
  }

  /**
   * Post content to platforms
   */
  async postContent(accountName, args) {
    const accountDir = this._getAccountDir(accountName);
    const config = this._loadAccountConfig(accountDir);
    const publisher = new Publisher(config);

    const contentDir = args.dir || args.content;
    if (!contentDir) {
      console.error('❌ Content directory required: --dir <path>');
      return;
    }

    const caption = args.caption;
    if (!caption) {
      console.error('❌ Caption required: --caption "<text>"');
      return;
    }

    // Load content metadata
    const content = this._loadContentFromDir(contentDir);

    try {
      const result = await publisher.post(content, caption, {
        platform: args.platform || 'tiktok',
        draft: args.draft || false,
        schedule: args.schedule || null
      });

      console.log(`✅ Posted successfully!`);
      if (result.postId) console.log(`📋 Post ID: ${result.postId}`);
      if (result.url) console.log(`🔗 URL: ${result.url}`);

    } catch (error) {
      console.error('❌ Posting failed:', error.message);
      process.exit(1);
    }
  }

  /**
   * Run analytics and optimization
   */
  async runAnalytics(accountName, args) {
    const accountDir = this._getAccountDir(accountName);
    const config = this._loadAccountConfig(accountDir);
    const analytics = new AnalyticsEngine(config);

    const days = parseInt(args.days || '7');
    const autoImplement = args['auto-improve'] || args['auto-implement'] || false;

    console.log(`📊 Analyzing ${accountName} performance (last ${days} days)...`);

    try {
      const report = await analytics.generateReport(accountDir, {
        days,
        autoImplement,
        includeRevenue: config.revenuecat?.enabled || false
      });

      console.log('\n═══ ANALYTICS SUMMARY ═══');
      console.log(`📈 Total Views: ${report.metrics.posts.totalViews.toLocaleString()}`);
      console.log(`💬 Avg Engagement: ${(report.metrics.posts.avgEngagementRate * 100).toFixed(1)}%`);
      console.log(`📝 Posts Published: ${report.metrics.posts.posts.length}`);

      if (report.insights.alerts.length > 0) {
        console.log('\n🚨 ALERTS:');
        report.insights.alerts.forEach(alert => {
          console.log(`  • ${alert.message} (${alert.severity})`);
        });
      }

      if (report.insights.opportunities.length > 0) {
        console.log('\n💡 OPPORTUNITIES:');
        report.insights.opportunities.forEach(opp => {
          console.log(`  • ${opp.message}`);
        });
      }

      if (autoImplement && report.insights.actions.length > 0) {
        console.log('\n🔧 ACTIONS IMPLEMENTED:');
        report.insights.actions.forEach(action => {
          console.log(`  ✅ ${action.description || action.type}`);
        });
      }

      console.log(`\n📋 Full report: ${report.reportPath}`);

    } catch (error) {
      console.error('❌ Analytics failed:', error.message);
      process.exit(1);
    }
  }

  /**
   * Configure account settings
   */
  async configureAccount(accountName, args) {
    const accountDir = this._getAccountDir(accountName);
    const configPath = path.join(accountDir, 'config.json');
    const config = this._loadAccountConfig(accountDir);

    let updated = false;

    // Update handle
    if (args.handle) {
      config.app = config.app || {};
      config.app.handle = args.handle;
      console.log(`📱 Handle set to: ${args.handle}`);
      updated = true;
    }

    // Update integration ID
    if (args['integration-id']) {
      config.postiz = config.postiz || {};
      config.postiz.integrationId = args['integration-id'];
      console.log(`🔗 Integration ID set to: ${args['integration-id']}`);
      updated = true;
    }

    // Update API key
    if (args['api-key']) {
      config.postiz = config.postiz || {};
      config.postiz.apiKey = args['api-key'];
      console.log(`🔑 API key updated`);
      updated = true;
    }

    if (updated) {
      fs.writeFileSync(configPath, JSON.stringify(config, null, 2));
      console.log(`✅ Configuration updated: ${configPath}`);
    } else {
      console.log('📋 Current configuration:');
      console.log(JSON.stringify(config, null, 2));
    }
  }

  /**
   * Show posting schedule for account
   */
  async showSchedule(accountName, args) {
    const accountDir = this._getAccountDir(accountName);
    const config = this._loadAccountConfig(accountDir);
    const scheduler = new PostingScheduler(config);

    try {
      const accountAge = scheduler.getAccountAge();
      const phase = scheduler.getAccountPhase();
      const postsPerWeek = scheduler.getPostsPerWeek();
      
      console.log(`\n📅 POSTING SCHEDULE FOR ${accountName.toUpperCase()}`);
      console.log(`═══════════════════════════════════════════`);
      console.log(`📊 Account Age: ${accountAge} days`);
      console.log(`🎯 Phase: ${phase}`);
      console.log(`📝 Target: ${postsPerWeek} posts/week`);
      console.log(`⏰ Timezone: ${config.posting?.timezone || 'Europe/London'}`);

      if (args.next) {
        // Show next posting slot
        console.log(`\n⏭️  NEXT POSTING SLOT:`);
        const nextSlot = scheduler.getNextPostingSlot([]);
        console.log(`   📅 ${this._formatDateTime(nextSlot)}`);
        console.log(`   ⏱️  ${this._formatTimeFromNow(nextSlot)}`);
        
      } else {
        // Show weekly schedule
        const weeks = parseInt(args.week || '1');
        const now = new Date();
        
        for (let w = 0; w < weeks; w++) {
          const weekStart = this._getWeekStart(now, w);
          const schedule = scheduler.getWeekSchedule(weekStart);
          
          console.log(`\n📅 WEEK ${w + 1} (${this._formatDate(weekStart)}):`);
          
          if (schedule.length === 0) {
            console.log(`   📭 No posts scheduled`);
          } else {
            schedule.forEach((slot, index) => {
              const dayName = this._getDayName(slot);
              const timeStr = this._formatTime(slot);
              const isPast = slot < now;
              const icon = isPast ? '✅' : '⏰';
              
              console.log(`   ${icon} ${dayName} ${timeStr}`);
            });
          }
        }
        
        // Check for overdue posts
        const recentPosts = []; // In real usage, load from database/analytics
        if (scheduler.isOverdue(recentPosts)) {
          console.log(`\n🚨 OVERDUE POSTS DETECTED!`);
          console.log(`   Some scheduled posts may have been missed.`);
        }
      }

    } catch (error) {
      console.error(`❌ Schedule error: ${error.message}`);
      process.exit(1);
    }
  }

  /**
   * List all accounts
   */
  async listAccounts() {
    if (!fs.existsSync(this.accountsDir)) {
      console.log('📭 No accounts found');
      return;
    }

    const accounts = fs.readdirSync(this.accountsDir).filter(name => 
      fs.statSync(path.join(this.accountsDir, name)).isDirectory()
    );

    if (accounts.length === 0) {
      console.log('📭 No accounts found');
      return;
    }

    console.log('📋 Available accounts:');
    accounts.forEach(name => {
      try {
        const config = this._loadAccountConfig(path.join(this.accountsDir, name));
        const handle = config.app?.handle || 'No handle set';
        const template = config.account?.template || 'Unknown template';
        console.log(`  • ${name} (${handle}) - ${template}`);
      } catch (error) {
        console.log(`  • ${name} - Configuration error`);
      }
    });
  }

  /**
   * Show help
   */
  showHelp() {
    console.log(`
Content Pipeline Framework - Universal CLI

COMMANDS:
  create <account> --template <template>     Create new account
  generate <account> --type <type> [--post] Generate content
  post <account> --dir <path> --caption "..." Post content
  schedule <account> [--next] [--week N]    Show posting schedule
  analytics <account> [--auto-improve]      Run analytics
  config <account> [--handle @handle]       Configure account
  list                                       List accounts
  help                                       Show this help

EXAMPLES:
  # Create new account
  ./cli.js create my-brand --template example-nostalgia
  
  # Generate and post content
  ./cli.js generate my-brand --type showcase --post --caption "Check this out"
  
  # Show posting schedule
  ./cli.js schedule my-brand              # This week's schedule
  ./cli.js schedule my-brand --next       # Next posting slot
  ./cli.js schedule my-brand --week 2     # Next 2 weeks
  
  # Run analytics with auto-optimization
  ./cli.js analytics my-brand --days 7 --auto-improve
`);
  }

  // Helper methods

  _getArgs(args) {
    const parsed = {};
    for (let i = 2; i < args.length; i++) {
      if (args[i].startsWith('--')) {
        const key = args[i].slice(2);
        const value = args[i + 1] && !args[i + 1].startsWith('--') ? args[i + 1] : true;
        parsed[key] = value;
        if (value !== true) i++; // Skip next arg if it was a value
      }
    }
    return parsed;
  }

  _getAccountDir(accountName) {
    // Sanitise: only allow alphanumeric, hyphens, underscores
    if (!/^[a-zA-Z0-9_-]+$/.test(accountName)) {
      console.error(`❌ Invalid account name '${accountName}' — only alphanumeric, hyphens, and underscores allowed`);
      process.exit(1);
    }
    const accountDir = path.join(this.accountsDir, accountName);
    // Path traversal guard: ensure resolved path stays within accountsDir
    if (!path.resolve(accountDir).startsWith(path.resolve(this.accountsDir))) {
      console.error(`❌ Invalid account path`);
      process.exit(1);
    }
    if (!fs.existsSync(accountDir)) {
      console.error(`❌ Account '${accountName}' not found`);
      process.exit(1);
    }
    return accountDir;
  }

  _loadAccountConfig(accountDir) {
    const configPath = path.join(accountDir, 'config.json');
    if (!fs.existsSync(configPath)) {
      console.error(`❌ Config not found: ${configPath}`);
      process.exit(1);
    }
    return JSON.parse(fs.readFileSync(configPath, 'utf8'));
  }

  _loadGenerator(templateName) {
    // Sanitise: only allow alphanumeric, hyphens, underscores
    if (!/^[a-zA-Z0-9_-]+$/.test(templateName)) {
      console.error(`❌ Invalid template name '${templateName}' — only alphanumeric, hyphens, and underscores allowed`);
      process.exit(1);
    }
    const generatorPath = path.join(this.templatesDir, templateName, 'generator.js');
    // Path traversal guard: ensure resolved path stays within templatesDir
    if (!path.resolve(generatorPath).startsWith(path.resolve(this.templatesDir))) {
      console.error(`❌ Invalid template path`);
      process.exit(1);
    }
    if (!fs.existsSync(generatorPath)) {
      console.error(`❌ Generator not found: ${generatorPath}`);
      process.exit(1);
    }
    return require(generatorPath);
  }

  _copyDirectory(src, dest) {
    // Path traversal guard: both src and dest must stay within framework root
    const resolvedSrc = path.resolve(src);
    const resolvedDest = path.resolve(dest);
    const resolvedRoot = path.resolve(this.frameworkRoot);
    if (!resolvedSrc.startsWith(resolvedRoot) || !resolvedDest.startsWith(resolvedRoot)) {
      console.error(`❌ Path traversal blocked: copy must stay within framework directory`);
      process.exit(1);
    }

    if (!fs.existsSync(dest)) {
      fs.mkdirSync(dest, { recursive: true });
    }
    
    const items = fs.readdirSync(src);
    for (const item of items) {
      // Skip dotfiles and suspicious names
      if (item.startsWith('.') || item.includes('..')) continue;
      const srcPath = path.join(src, item);
      const destPath = path.join(dest, item);
      
      if (fs.statSync(srcPath).isDirectory()) {
        this._copyDirectory(srcPath, destPath);
      } else {
        fs.copyFileSync(srcPath, destPath);
      }
    }
  }

  _loadContentFromDir(contentDir) {
    // Load generated content metadata
    const slides = [];
    for (let i = 1; i <= 10; i++) {
      const slidePath = path.join(contentDir, `slide${i}.png`);
      if (fs.existsSync(slidePath)) {
        slides.push(slidePath);
      }
    }

    return {
      slides,
      outputDir: contentDir,
      contentType: path.basename(contentDir).split('-').slice(-1)[0]
    };
  }

  // Date/time formatting helpers for schedule command

  _formatDateTime(date) {
    const options = {
      weekday: 'short',
      year: 'numeric',
      month: 'short',
      day: 'numeric',
      hour: '2-digit',
      minute: '2-digit',
      timeZoneName: 'short'
    };
    return date.toLocaleDateString('en-GB', options);
  }

  _formatDate(date) {
    const options = {
      weekday: 'short',
      month: 'short',
      day: 'numeric'
    };
    return date.toLocaleDateString('en-GB', options);
  }

  _formatTime(date) {
    const options = {
      hour: '2-digit',
      minute: '2-digit'
    };
    return date.toLocaleTimeString('en-GB', options);
  }

  _formatTimeFromNow(date) {
    const now = new Date();
    const diffMs = date.getTime() - now.getTime();
    const diffHours = Math.round(diffMs / (1000 * 60 * 60));
    const diffDays = Math.floor(diffHours / 24);
    
    if (diffMs < 0) {
      return 'Overdue';
    } else if (diffHours < 1) {
      const diffMins = Math.round(diffMs / (1000 * 60));
      return `in ${diffMins} minutes`;
    } else if (diffHours < 24) {
      return `in ${diffHours} hours`;
    } else if (diffDays === 1) {
      return 'tomorrow';
    } else {
      return `in ${diffDays} days`;
    }
  }

  _getDayName(date) {
    const days = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'];
    return days[date.getDay()];
  }

  _getWeekStart(referenceDate, weekOffset = 0) {
    const date = new Date(referenceDate);
    date.setDate(date.getDate() + (weekOffset * 7));
    
    // Get Monday of this week
    const dayOfWeek = date.getDay();
    const diffToMonday = dayOfWeek === 0 ? -6 : 1 - dayOfWeek; // Convert Sunday=0 to -6
    date.setDate(date.getDate() + diffToMonday);
    date.setHours(0, 0, 0, 0);
    
    return date;
  }

  async _postGenerated(accountDir, config, content, args) {
    const publisher = new Publisher(config);
    
    // Generate caption if not provided
    const caption = args.caption || content.hook || 'New post';
    
    try {
      const result = await publisher.post(content, caption, {
        draft: args.draft !== false // Default to draft
      });
      console.log('📤 Posted as draft - add sound and publish from TikTok inbox');
    } catch (error) {
      console.error('❌ Auto-posting failed:', error.message);
    }
  }
}

// Run CLI if called directly
if (require.main === module) {
  const cli = new ContentPipelineCLI();
  cli.run().catch(error => {
    console.error('Fatal error:', error.message);
    process.exit(1);
  });
}

module.exports = ContentPipelineCLI;

path: config.example.json
content: {
  "account": {
    "name": "your-brand-name",
    "handle": "@yourtiktok",
    "niche": "your-niche",
    "createdAt": "2026-01-01T00:00:00Z"
  },
  "postiz": {
    "apiKey": "YOUR_POSTIZ_API_KEY",
    "integrationId": "YOUR_TIKTOK_INTEGRATION_ID",
    "integrationIds": {
      "tiktok": "YOUR_TIKTOK_INTEGRATION_ID",
      "instagram": "YOUR_INSTAGRAM_INTEGRATION_ID",
      "youtube": "YOUR_YOUTUBE_INTEGRATION_ID"
    }
  },
  "posting": {
    "timezone": "Europe/London",
    "schedule": ["17:00"],
    "postsPerWeek": 7,
    "optimalHours": [14, 15, 16, 17, 18],
    "maxPerDay": 1,
    "newAccountMaxPerDay": 2,
    "newAccountDailyDays": 30,
    "defaultSettings": {
      "tiktok": {
        "privacy_level": "PUBLIC_TO_EVERYONE",
        "duet": true,
        "stitch": true,
        "comment": true,
        "autoAddMusic": "yes",
        "brand_content_toggle": false,
        "brand_organic_toggle": false,
        "content_posting_method": "DIRECT_POST"
      }
    }
  },
  "content": {
    "types": {
      "showcase": {
        "description": "Main content type for your niche",
        "slides": 6
      }
    },
    "slideSize": { "width": 1024, "height": 1536 },
    "hashtagSets": {
      "default": ["#yourniche", "#fyp", "#viral"]
    }
  },
  "hooks": {
    "showcase": [
      "You need to see this 🔥",
      "Nobody talks about this 🤫",
      "Wait for it... 👀"
    ]
  },
  "questionHooks": {
    "showcase": [
      "Rate this 1-10 👇",
      "Thoughts? Drop them below 💬"
    ]
  },
  "searchKeywords": {
    "showcase": ["your niche keyword", "another keyword"]
  },
  "ctaSlides": [
    "Save this for later 💾",
    "Share with someone who needs this 🤝"
  ],
  "revenuecat": {
    "enabled": false
  }
}


path: core/AnalyticsEngine.js
content: /**
 * Analytics Engine
 * 
 * Cross-platform analytics aggregation and optimization engine.
 * Pulls data from Postiz, analyzes performance, and auto-implements improvements.
 */

const fs = require('fs');
const path = require('path');
const { execSync } = require('child_process');

class AnalyticsEngine {
  constructor(config) {
    this.config = config;
    this.postiz = config.postiz || {};
    // Prefer env var over config file for API key
    this.postiz.[REDACTED_SECRET] || this.postiz.apiKey;
    this.revenuecat = config.revenuecat;
  }

  /**
   * Escape a string for safe use in shell arguments.
   * @private
   */
  _shellEscape(str) {
    if (typeof str !== 'string') str = String(str);
    return "'" + str.replace(/'/g, "'\\''") + "'";
  }

  /**
   * Generate comprehensive analytics report
   * 
   * @param {string} accountDir - Account directory path
   * @param {Object} options - Report options
   * @returns {Promise<Object>} Analytics report with actionable insights
   */
  async generateReport(accountDir, options = {}) {
    const {
      days = 7,
      autoImplement = false,
      includeRevenue = false
    } = options;

    console.log(`📊 Generating ${days}-day analytics report...`);

    // Collect data from multiple sources
    const [
      postMetrics,
      platformMetrics,
      revenueMetrics,
      hookPerformance
    ] = await Promise.all([
      this._getPostMetrics(days),
      this._getPlatformMetrics(days),
      includeRevenue ? this._getRevenueMetrics(days) : null,
      this._analyzeHookPerformance(accountDir, days)
    ]);

    // Generate insights and recommendations
    const insights = this._generateInsights({
      posts: postMetrics,
      platform: platformMetrics,
      revenue: revenueMetrics,
      hooks: hookPerformance
    });

    // Auto-implement improvements if enabled
    if (autoImplement && insights.actions.length > 0) {
      console.log('🔧 Auto-implementing improvements...');
      await this._implementActions(accountDir, insights.actions);
    }

    // Save report
    const reportPath = this._saveReport(accountDir, {
      period: { days, startDate: this._getDateDaysAgo(days) },
      metrics: { posts: postMetrics, platform: platformMetrics, revenue: revenueMetrics },
      insights,
      timestamp: new Date().toISOString()
    });

    return {
      insights,
      metrics: { posts: postMetrics, platform: platformMetrics, revenue: revenueMetrics },
      reportPath
    };
  }

  /**
   * Get post-level metrics from Postiz
   * @private
   */
  async _getPostMetrics(days) {
    try {
      // Get recent posts
      const cmd = `POSTIZ_API_KEY=${this._shellEscape(this.postiz.apiKey)} postiz posts:list --limit 50`;
      const output = execSync(cmd, { encoding: 'utf8' });
      const posts = JSON.parse(output);

      // Filter posts from last N days
      const cutoffDate = new Date();
      cutoffDate.setDate(cutoffDate.getDate() - days);
      
      const recentPosts = posts.filter(post => 
        new Date(post.publishDate) >= cutoffDate
      );

      // Get analytics for each post
      const postsWithAnalytics = [];
      for (const post of recentPosts) {
        try {
          const analyticsCmd = `POSTIZ_API_KEY=${this._shellEscape(this.postiz.apiKey)} postiz analytics:post ${this._shellEscape(post.id)}`;
          const analyticsOutput = execSync(analyticsCmd, { encoding: 'utf8' });
          const analytics = JSON.parse(analyticsOutput);
          
          postsWithAnalytics.push({
            ...post,
            analytics: analytics.metrics || {}
          });
        } catch (error) {
          // Analytics might not be available immediately
          postsWithAnalytics.push({
            ...post,
            analytics: { views: 0, likes: 0, comments: 0, shares: 0 }
          });
        }
      }

      return this._aggregatePostMetrics(postsWithAnalytics);
      
    } catch (error) {
      console.error('Failed to fetch post metrics:', error.message);
      return { posts: [], totalViews: 0, totalEngagement: 0, avgEngagementRate: 0 };
    }
  }

  /**
   * Get platform-level metrics
   * @private
   */
  async _getPlatformMetrics(days) {
    try {
      const integrationId = this.postiz.integrationIds?.tiktok || this.postiz.integrationId;
      const cmd = `POSTIZ_API_KEY=${this._shellEscape(this.postiz.apiKey)} postiz analytics:platform ${this._shellEscape(integrationId)}`;
      const output = execSync(cmd, { encoding: 'utf8' });
      return JSON.parse(output);
    } catch (error) {
      console.error('Failed to fetch platform metrics:', error.message);
      return { followers: 0, totalViews: 0, profileViews: 0 };
    }
  }

  /**
   * Analyze hook performance patterns
   * @private
   */
  async _analyzeHookPerformance(accountDir, days) {
    const hookPerformancePath = path.join(accountDir, 'analytics', 'hook-performance.json');
    
    if (!fs.existsSync(hookPerformancePath)) {
      return { topPerformers: [], underPerformers: [], recommendations: [] };
    }

    const hookData = JSON.parse(fs.readFileSync(hookPerformancePath, 'utf8'));
    
    // Analyze patterns
    const analysis = {
      topPerformers: this._getTopPerformingHooks(hookData, 5),
      underPerformers: this._getUnderPerformingHooks(hookData, 5),
      contentTypeInsights: this._analyzeContentTypePerformance(hookData),
      recommendations: []
    };

    // Generate hook recommendations
    analysis.recommendations = this._generateHookRecommendations(analysis);

    return analysis;
  }

  /**
   * Generate actionable insights from metrics
   * @private
   */
  _generateInsights(data) {
    const insights = {
      summary: this._generateSummary(data),
      alerts: [],
      opportunities: [],
      actions: []
    };

    // Performance alerts
    if (data.posts.avgEngagementRate < 0.02) {
      insights.alerts.push({
        type: 'low_engagement',
        message: 'Engagement rate below 2% - hook optimization needed',
        severity: 'high'
      });
      
      insights.actions.push({
        type: 'hook_diversification',
        description: 'Rotate to top-performing hook patterns',
        priority: 'high'
      });
    }

    // Growth opportunities
    if (data.hooks.topPerformers?.length > 0) {
      const topHook = data.hooks.topPerformers[0];
      insights.opportunities.push({
        type: 'scale_winner',
        message: `"${topHook.pattern}" hooks averaging ${topHook.avgViews} views`,
        action: 'Create 3 variations of this hook pattern'
      });

      insights.actions.push({
        type: 'create_hook_variations',
        hookPattern: topHook.pattern,
        variations: 3,
        priority: 'medium'
      });
    }

    // Posting optimization
    if (data.posts.posts.length < 3) {
      insights.alerts.push({
        type: 'low_posting_frequency',
        message: 'Less than 3 posts this period - consistency important for growth',
        severity: 'medium'
      });
    }

    return insights;
  }

  /**
   * Auto-implement optimization actions
   * @private
   */
  async _implementActions(accountDir, actions) {
    for (const action of actions) {
      try {
        await this._executeAction(accountDir, action);
        console.log(`✅ Implemented: ${action.description || action.type}`);
      } catch (error) {
        console.error(`❌ Failed to implement ${action.type}:`, error.message);
      }
    }
  }

  /**
   * Execute a specific optimization action
   * @private
   */
  async _executeAction(accountDir, action) {
    switch (action.type) {
      case 'hook_diversification':
        await this._rotateToTopHooks(accountDir);
        break;
        
      case 'create_hook_variations':
        await this._generateHookVariations(accountDir, action.hookPattern, action.variations);
        break;
        
      case 'adjust_posting_times':
        await this._optimizePostingSchedule(accountDir, action.optimalTimes);
        break;
        
      default:
        console.log(`⚠️ Unknown action type: ${action.type}`);
    }
  }

  /**
   * Save analytics report to file
   * @private
   */
  _saveReport(accountDir, reportData) {
    const reportsDir = path.join(accountDir, 'analytics', 'reports');
    if (!fs.existsSync(reportsDir)) {
      fs.mkdirSync(reportsDir, { recursive: true });
    }
    
    const filename = `report-${new Date().toISOString().split('T')[0]}.json`;
    const reportPath = path.join(reportsDir, filename);
    
    fs.writeFileSync(reportPath, JSON.stringify(reportData, null, 2));
    
    // Also save a human-readable summary
    const summaryPath = path.join(reportsDir, `summary-${new Date().toISOString().split('T')[0]}.md`);
    fs.writeFileSync(summaryPath, this._generateMarkdownSummary(reportData));
    
    return reportPath;
  }

  /**
   * Generate human-readable markdown summary
   * @private
   */
  _generateMarkdownSummary(reportData) {
    const { period, metrics, insights } = reportData;
    
    return `# Analytics Report - ${period.startDate}

## Summary
- **Posts Published**: ${metrics.posts.posts.length}
- **Total Views**: ${metrics.posts.totalViews.toLocaleString()}
- **Avg Engagement Rate**: ${(metrics.posts.avgEngagementRate * 100).toFixed(1)}%
- **Platform Followers**: ${metrics.platform.followers?.toLocaleString() || 'N/A'}

## Alerts
${insights.alerts.map(alert => `- 🚨 **${alert.severity.toUpperCase()}**: ${alert.message}`).join('\n') || 'None'}

## Opportunities
${insights.opportunities.map(opp => `- 💡 ${opp.message}`).join('\n') || 'None'}

## Actions Taken
${insights.actions.map(action => `- ✅ ${action.description || action.type}`).join('\n') || 'None'}

---
*Generated ${new Date().toLocaleString()}*`;
  }

  // Helper methods for metric aggregation and analysis
  _aggregatePostMetrics(posts) {
    if (posts.length === 0) {
      return { posts: [], totalViews: 0, totalEngagement: 0, avgEngagementRate: 0 };
    }

    const totalViews = posts.reduce((sum, post) => sum + (post.analytics.views || 0), 0);
    const totalEngagement = posts.reduce((sum, post) => 
      sum + (post.analytics.likes || 0) + (post.analytics.comments || 0) + (post.analytics.shares || 0), 0);
    
    return {
      posts,
      totalViews,
      totalEngagement,
      avgEngagementRate: totalViews > 0 ? totalEngagement / totalViews : 0,
      avgViewsPerPost: Math.round(totalViews / posts.length)
    };
  }

  _getDateDaysAgo(days) {
    const date = new Date();
    date.setDate(date.getDate() - days);
    return date.toISOString().split('T')[0];
  }

  _generateSummary(data) {
    return {
      postsPublished: data.posts.posts.length,
      totalViews: data.posts.totalViews,
      avgEngagementRate: data.posts.avgEngagementRate,
      topPerformingHook: data.hooks.topPerformers?.[0]?.pattern || 'None identified'
    };
  }

  _getTopPerformingHooks(hookData, limit = 5) {
    return Object.entries(hookData)
      .map(([pattern, data]) => ({ pattern, ...data }))
      .sort((a, b) => b.avgViews - a.avgViews)
      .slice(0, limit);
  }

  _getUnderPerformingHooks(hookData, limit = 5) {
    return Object.entries(hookData)
      .map(([pattern, data]) => ({ pattern, ...data }))
      .sort((a, b) => a.avgViews - b.avgViews)
      .slice(0, limit);
  }
}

module.exports = AnalyticsEngine;

path: core/ContentGenerator.js
content: /**
 * Content Generator Base Class
 * 
 * Abstract interface for all content generators.
 * Each niche extends this with domain-specific logic (e.g. gaming, property, collectibles).
 */

class ContentGenerator {
  constructor(config) {
    this.config = config;
    this.assetsDir = config.assetsDir;
    this.dataDir = config.dataDir;
  }

  /**
   * Generate content based on type and options
   * 
   * @param {string} contentType - Type of content to generate
   * @param {Object} options - Content-specific options
   * @param {string} outputDir - Where to save generated content
   * @returns {Promise<Object>} Generated content metadata
   */
  async generate(contentType, options = {}, outputDir) {
    if (!this.getSupportedTypes().includes(contentType)) {
      throw new Error(`Content type '${contentType}' not supported by ${this.constructor.name}`);
    }

    const startTime = Date.now();
    const result = await this._generateContent(contentType, options, outputDir);
    const duration = Date.now() - startTime;

    return {
      ...result,
      contentType,
      duration,
      timestamp: new Date().toISOString(),
      generator: this.constructor.name
    };
  }

  /**
   * Get supported content types for this generator
   * @returns {Array<string>} Supported content types
   */
  getSupportedTypes() {
    throw new Error('getSupportedTypes must be implemented by subclass');
  }

  /**
   * Get content type configuration
   * @param {string} contentType 
   * @returns {Object} Content type config
   */
  getContentConfig(contentType) {
    return this.config.content.types[contentType] || {};
  }

  /**
   * Generate hook/caption for content type
   * @param {string} contentType 
   * @param {Object} context - Content context (player, property, etc.)
   * @returns {string} Generated hook
   */
  generateHook(contentType, context = {}) {
    const hooks = this.config.hooks[contentType] || [];
    if (hooks.length === 0) return '';

    // Simple random selection - could be made smarter with performance data
    const hook = hooks[Math.floor(Math.random() * hooks.length)];
    
    // Replace placeholders
    return this._replacePlaceholders(hook, context);
  }

  /**
   * Generate a question hook for driving comment engagement.
   * Returns null if no question hooks are configured for this content type.
   */
  generateQuestionHook(contentType, context = {}) {
    const questionHooks = (this.config.questionHooks || {})[contentType] || [];
    if (questionHooks.length === 0) return null;

    const hook = questionHooks[Math.floor(Math.random() * questionHooks.length)];
    return this._replacePlaceholders(hook, context);
  }

  /**
   * Get the slide 1 hook text — 50/50 chance of using a question hook
   * when one is available, to drive comment engagement.
   * @returns {{ text: string, isQuestion: boolean }}
   */
  getSlide1Hook(contentType, context = {}) {
    const questionHook = this.generateQuestionHook(contentType, context);
    if (questionHook && Math.random() < 0.5) {
      return { text: questionHook, isQuestion: true };
    }
    return { text: this.generateHook(contentType, context), isQuestion: false };
  }

  /**
   * Build a caption, appending the question hook if present.
   */
  buildCaption(hook, slide1Hook) {
    if (slide1Hook.isQuestion) {
      return `${hook}\n\n${slide1Hook.text}`;
    }
    return hook;
  }

  /**
   * Get optimal hashtags for content type
   * @param {string} contentType 
   * @returns {Array<string>} Hashtags
   */
  getHashtags(contentType) {
    const hashtagSets = this.config.content.hashtagSets || {};
    return hashtagSets[contentType] || hashtagSets.default || [];
  }

  /**
   * Abstract method - implement in subclass
   * @private
   */
  async _generateContent(contentType, options, outputDir) {
    throw new Error('_generateContent must be implemented by subclass');
  }

  /**
   * Get SEO keyword overlay text for a given content type and slide number.
   * Returns 1-2 keywords to overlay on the slide, cycling through available keywords.
   * 
   * @param {string} contentType - Content type key
   * @param {number} slideNumber - 1-indexed slide number
   * @returns {string|null} Keyword text to overlay, or null if no keywords configured
   */
  getSearchKeywordOverlay(contentType, slideNumber) {
    const keywords = (this.config.searchKeywords || {})[contentType];
    if (!keywords || keywords.length === 0) return null;

    // Pick 1-2 keywords per slide, cycling through the list
    const idx = (slideNumber - 1) % keywords.length;
    const picked = [keywords[idx]];
    // Add a second keyword on even slides if available
    if (slideNumber % 2 === 0 && keywords.length > 1) {
      const idx2 = (idx + 1) % keywords.length;
      picked.push(keywords[idx2]);
    }
    return picked.join(' · ');
  }

  /**
   * Build an ImageMagick argument fragment that composites a semi-transparent
   * keyword overlay at the bottom of a slide. Append this to an existing
   * `convert` command (before the output path).
   * 
   * @param {string} keywordText - Text to overlay
   * @param {Object} slideSize - { width, height }
   * @returns {string} ImageMagick CLI fragment
   */
  buildKeywordOverlayArgs(keywordText, slideSize) {
    if (!keywordText) return '';
    const { width } = slideSize;
    const escapedText = keywordText.replace(/'/g, "'\\''");
    // Small, semi-transparent white text at the bottom
    return ` \\( -size ${width}x60 xc:none -fill "rgba(255,255,255,0.35)" ` +
           `-font Arial -pointsize 22 -gravity center -annotate +0+0 '${escapedText}' \\) ` +
           `-gravity south -composite`;
  }

  /**
   * Replace placeholders in text with context values
   * @private
   */
  _replacePlaceholders(text, context) {
    return text.replace(/\{(\w+)\}/g, (match, key) => {
      return context[key] || match;
    });
  }

  /**
   * Ensure output directory exists
   * @private
   */
  _ensureOutputDir(outputDir) {
    const fs = require('fs');
    const path = require('path');
    
    if (!fs.existsSync(outputDir)) {
      fs.mkdirSync(outputDir, { recursive: true });
    }
    
    return outputDir;
  }
}

module.exports = ContentGenerator;

path: core/PostingScheduler.js
content: /**
 * PostingScheduler - Smart posting frequency and timing engine
 * 
 * Handles posting frequency based on account age:
 * - New accounts (0-30 days): Daily posting at optimal times
 * - Transition (25-30 days): Gradual taper to 4 posts/week
 * - Established (31+ days): 3-4 posts/week on optimal days
 * 
 * Uses research-backed optimal timing for maximum engagement.
 */

class PostingScheduler {
  constructor(config) {
    this.config = config;
    this.timezone = config.posting?.timezone || 'Europe/London';
    
    // Research-backed optimal posting times
    this.optimalTimes = {
      // Best days: Tuesday, Wednesday, Thursday
      0: [12, 18], // Sunday - fallback times
      1: [12, 18], // Monday - fallback times  
      2: [17],     // Tuesday - 5pm optimal
      3: [14, 15, 16, 17], // Wednesday - 2-5pm optimal
      4: [15, 16, 17], // Thursday - 3-5pm optimal
      5: [12, 18], // Friday - fallback times
      6: [12, 18]  // Saturday - fallback times
    };
    
    // Best engagement days (prioritized)
    this.optimalDays = [2, 3, 4]; // Tuesday, Wednesday, Thursday
    this.fallbackDays = [1, 5, 0, 6]; // Monday, Friday, Sunday, Saturday
  }

  /**
   * Get account age in days since creation
   */
  getAccountAge() {
    if (!this.config.account?.createdAt) {
      throw new Error('Account createdAt timestamp not found in config');
    }
    
    const createdAt = new Date(this.config.account.createdAt);
    const now = new Date();
    const diffMs = now.getTime() - createdAt.getTime();
    const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));
    
    return Math.max(0, diffDays);
  }

  /**
   * Check if account is in new account phase (< 30 days)
   */
  isNewAccount() {
    return this.getAccountAge() < 30;
  }

  /**
   * Get target posts per week based on account age
   */
  getPostsPerWeek() {
    const age = this.getAccountAge();
    
    if (age < 25) {
      return 7; // Daily posting
    } else if (age < 31) {
      // Transition period: taper from 7 to 4
      // Day 25: 6 posts, Day 26: 5 posts, Day 27-30: 4 posts
      if (age === 25) return 6;
      if (age === 26) return 5;
      return 4;
    } else {
      return this._getEstablishedFrequency(); // 3-4 posts/week
    }
  }

  /**
   * Get next optimal posting slot considering recent posts
   * @param {Array} recentPosts - Array of {publishDate: ISO string}
   * @returns {Date} - Next optimal posting time
   */
  getNextPostingSlot(recentPosts = []) {
    const now = new Date();
    const recentDates = recentPosts.map(p => new Date(p.publishDate));
    
    // Check if already posted today
    const today = this._getDateInTimezone(now);
    const hasPostedToday = recentDates.some(date => 
      this._isSameDay(date, today)
    );
    
    if (hasPostedToday) {
      // Find next available day
      return this._getNextAvailableSlot(recentDates, 1);
    }
    
    // Check if should post today based on schedule
    if (this.shouldPostToday(recentPosts)) {
      return this._getOptimalTimeToday();
    }
    
    // Find next scheduled day
    return this._getNextAvailableSlot(recentDates, 0);
  }

  /**
   * Get posting schedule for a week starting from given date
   * @param {Date} weekStartDate - Start of week (should be Monday)
   * @returns {Array<Date>} - Array of posting times for the week
   */
  getWeekSchedule(weekStartDate) {
    const postsPerWeek = this.getPostsPerWeek();
    const schedule = [];
    
    if (postsPerWeek === 7) {
      // New account: daily posting
      for (let i = 0; i < 7; i++) {
        const date = new Date(weekStartDate);
        date.setDate(date.getDate() + i);
        const dayOfWeek = date.getDay();
        const hour = this._getBestHourForDay(dayOfWeek);
        
        date.setHours(hour, 0, 0, 0);
        schedule.push(date);
      }
    } else {
      // Established account: prioritize optimal days
      const targetDays = this._getTargetDaysForWeek(postsPerWeek);
      
      targetDays.forEach(dayOffset => {
        const date = new Date(weekStartDate);
        date.setDate(date.getDate() + dayOffset);
        const dayOfWeek = date.getDay();
        const hour = this._getBestHourForDay(dayOfWeek);
        
        date.setHours(hour, 0, 0, 0);
        schedule.push(date);
      });
    }
    
    return schedule.sort((a, b) => a.getTime() - b.getTime());
  }

  /**
   * Quick check: should we post today based on frequency and recent posts?
   * @param {Array} recentPosts - Array of {publishDate: ISO string}
   * @returns {boolean}
   */
  shouldPostToday(recentPosts = []) {
    const now = new Date();
    const today = this._getDateInTimezone(now);
    const recentDates = recentPosts.map(p => new Date(p.publishDate));
    
    // Never post if already posted today
    const hasPostedToday = recentDates.some(date => 
      this._isSameDay(date, today)
    );
    if (hasPostedToday) return false;
    
    // For new accounts: post daily
    if (this.isNewAccount()) return true;
    
    // For established accounts: check if today is scheduled
    const postsPerWeek = this.getPostsPerWeek();
    const targetDays = this._getTargetDaysForWeek(postsPerWeek);
    const todayOffset = this._getDayOfWeekOffset(today);
    
    return targetDays.includes(todayOffset);
  }

  // Private helper methods

  _getEstablishedFrequency() {
    // Vary between 3-4 posts/week for established accounts
    // Could be based on performance metrics in the future
    return 4;
  }

  _getBestHourForDay(dayOfWeek) {
    const hours = this.optimalTimes[dayOfWeek];
    // Return first (primary) optimal hour for the day
    return hours[0];
  }

  _getTargetDaysForWeek(postsPerWeek) {
    const optimalDays = [1, 2, 3]; // Monday=0, so Tue=1, Wed=2, Thu=3
    const fallbackDays = [0, 4, 5, 6]; // Mon, Fri, Sat, Sun
    
    if (postsPerWeek <= 3) {
      return optimalDays.slice(0, postsPerWeek);
    } else if (postsPerWeek === 4) {
      return [...optimalDays, fallbackDays[0]]; // Add Monday
    } else if (postsPerWeek === 5) {
      return [...optimalDays, fallbackDays[0], fallbackDays[1]]; // Add Mon, Fri
    } else if (postsPerWeek === 6) {
      return [...optimalDays, ...fallbackDays.slice(0, 3)]; // Add Mon, Fri, Sat
    } else {
      return [0, 1, 2, 3, 4, 5, 6]; // All days
    }
  }

  _getOptimalTimeToday() {
    const now = new Date();
    const today = this._getDateInTimezone(now);
    const dayOfWeek = today.getDay();
    const hour = this._getBestHourForDay(dayOfWeek);
    
    // Set to optimal time for today
    const optimalTime = new Date(today);
    optimalTime.setHours(hour, 0, 0, 0);
    
    // If optimal time has passed, schedule for tomorrow
    if (optimalTime <= now) {
      return this._getNextAvailableSlot([], 1);
    }
    
    return optimalTime;
  }

  _getNextAvailableSlot(recentDates, daysFromNow = 0) {
    const now = new Date();
    let candidate = new Date(now);
    candidate.setDate(candidate.getDate() + daysFromNow);
    
    const postsPerWeek = this.getPostsPerWeek();
    
    // For established accounts, find next optimal day
    if (!this.isNewAccount()) {
      const targetDays = this._getTargetDaysForWeek(postsPerWeek);
      
      for (let i = 0; i < 14; i++) { // Search up to 2 weeks
        const testDate = new Date(candidate);
        testDate.setDate(testDate.getDate() + i);
        const dayOffset = this._getDayOfWeekOffset(testDate);
        
        const hasPostedThisDay = recentDates.some(date => 
          this._isSameDay(date, testDate)
        );
        
        if (targetDays.includes(dayOffset) && !hasPostedThisDay) {
          const dayOfWeek = testDate.getDay();
          const hour = this._getBestHourForDay(dayOfWeek);
          testDate.setHours(hour, 0, 0, 0);
          return testDate;
        }
      }
    }
    
    // For new accounts or fallback: next day at optimal time
    candidate.setDate(candidate.getDate() + 1);
    const dayOfWeek = candidate.getDay();
    const hour = this._getBestHourForDay(dayOfWeek);
    candidate.setHours(hour, 0, 0, 0);
    
    return candidate;
  }

  _getDateInTimezone(date) {
    // Simple timezone handling using built-in Date methods
    // This is a basic implementation - for production might need more robust solution
    return new Date(date.toLocaleString('en-US', { timeZone: this.timezone }));
  }

  _isSameDay(date1, date2) {
    const d1 = this._getDateInTimezone(date1);
    const d2 = this._getDateInTimezone(date2);
    
    return d1.getFullYear() === d2.getFullYear() &&
           d1.getMonth() === d2.getMonth() &&
           d1.getDate() === d2.getDate();
  }

  _getDayOfWeekOffset(date) {
    // Return day offset from Monday (Monday = 0, Tuesday = 1, etc.)
    const dayOfWeek = date.getDay();
    return dayOfWeek === 0 ? 6 : dayOfWeek - 1; // Convert Sunday=0 to Sunday=6
  }

  /**
   * Get human-readable account phase
   */
  getAccountPhase() {
    const age = this.getAccountAge();
    
    if (age < 25) return 'New Account (Daily Posting)';
    if (age < 31) return 'Transition Phase (Tapering Down)';
    return 'Established Account (Optimal Schedule)';
  }

  /**
   * Check if any posts are overdue based on schedule
   * @param {Array} recentPosts - Recent posts with publishDate
   * @returns {boolean}
   */
  isOverdue(recentPosts = []) {
    const now = new Date();
    const lastWeek = new Date(now);
    lastWeek.setDate(lastWeek.getDate() - 7);
    
    const schedule = this.getWeekSchedule(lastWeek);
    const recentDates = recentPosts.map(p => new Date(p.publishDate));
    
    return schedule.some(scheduledTime => {
      if (scheduledTime > now) return false; // Future posts can't be overdue
      
      // Check if we posted within 24 hours of scheduled time
      const posted = recentDates.some(postDate => {
        const diffHours = Math.abs(postDate.getTime() - scheduledTime.getTime()) / (1000 * 60 * 60);
        return diffHours < 24;
      });
      
      return !posted;
    });
  }
}

module.exports = PostingScheduler;

path: core/Publisher.js
content: /**
 * Universal Content Publisher
 * 
 * Handles posting to TikTok (and other platforms) via Postiz API.
 * Abstracts away platform-specific settings and provides unified interface.
 */

const fs = require('fs');
const path = require('path');
const { execSync } = require('child_process');

class Publisher {
  constructor(config) {
    this.config = config;
    this.postiz = config.postiz || {};
    // Prefer env var over config file for API key
    this.postiz.[REDACTED_SECRET] || this.postiz.apiKey;
    
    if (!this.postiz.apiKey) {
      throw new Error('Postiz API key required — set POSTIZ_API_KEY env var or config.postiz.apiKey');
    }
  }

  /**
   * Post content to TikTok via Postiz
   * 
   * @param {Object} content - Generated content object
   * @param {string} caption - Post caption
   * @param {Object} options - Publishing options
   * @returns {Promise<Object>} Post result
   */
  async post(content, caption, options = {}) {
    const {
      platform = 'tiktok',
      schedule = null,
      draft = false,
      title = caption,
      settings = {}
    } = options;

    // Get platform integration ID
    const integrationId = this.postiz.integrationIds[platform] || this.postiz.integrationId;
    if (!integrationId) {
      throw new Error(`No integration ID configured for platform: ${platform}`);
    }

    // Upload media files
    const mediaUrls = await this._uploadMedia(content.slides || content.media || []);
    
    // Merge platform settings
    const platformSettings = {
      ...this.config.posting?.defaultSettings?.[platform],
      ...this.config.platforms?.[platform]?.settings,
      ...settings
    };

    // Build Postiz command
    const cmd = this._buildPostizCommand({
      integrationId,
      mediaUrls,
      caption,
      title,
      schedule,
      settings: platformSettings,
      draft
    });

    try {
      console.log(`📤 Publishing to ${platform}...`);
      const output = execSync(cmd, { encoding: 'utf8' });
      const result = this._parsePostizOutput(output);
      
      // Save post metadata
      await this._savePostMetadata(content, result, {
        platform,
        caption,
        settings: platformSettings,
        timestamp: new Date().toISOString()
      });

      return result;
      
    } catch (error) {
      console.error('❌ Publishing failed:', error.message);
      throw error;
    }
  }

  /**
   * Schedule batch of posts
   * 
   * @param {Array} contentBatch - Array of content objects
   * @param {Object} scheduleOptions - Scheduling preferences
   * @returns {Promise<Array>} Scheduled posts
   */
  async scheduleBatch(contentBatch, scheduleOptions = {}) {
    const {
      spacing = 3, // hours between posts
      startTime = null,
      optimalHours = this.config.posting?.optimalHours || [12, 18, 21],
      timezone = this.config.posting?.timezone || 'UTC'
    } = scheduleOptions;

    const results = [];
    let currentTime = startTime ? new Date(startTime) : this._getNextOptimalTime(optimalHours);

    for (const content of contentBatch) {
      const caption = content.caption || content.hook || 'New post';
      
      try {
        const result = await this.post(content, caption, {
          schedule: currentTime.toISOString()
        });
        
        results.push({ content, result, scheduledFor: currentTime.toISOString() });
        
        // Advance to next posting slot
        currentTime = new Date(currentTime.getTime() + spacing * 60 * 60 * 1000);
        
      } catch (error) {
        console.error(`Failed to schedule post:`, error.message);
        results.push({ content, error: error.message });
      }
    }

    return results;
  }

  /**
   * Upload media files to Postiz
   * @private
   */
  async _uploadMedia(mediaPaths) {
    const mediaUrls = [];
    
    for (const mediaPath of mediaPaths) {
      if (!fs.existsSync(mediaPath)) {
        throw new Error(`Media file not found: ${mediaPath}`);
      }
      
      try {
        console.log(`📎 Uploading ${path.basename(mediaPath)}...`);
        const cmd = `POSTIZ_API_KEY=${this._shellEscape(this.postiz.apiKey)} postiz upload ${this._shellEscape(mediaPath)}`;
        const output = execSync(cmd, { encoding: 'utf8' });
        const url = output.trim().split('\n').pop(); // Get last line (URL)
        mediaUrls.push(url);
      } catch (error) {
        throw new Error(`Failed to upload ${mediaPath}: ${error.message}`);
      }
    }
    
    return mediaUrls;
  }

  /**
   * Build Postiz CLI command
   * @private
   */
  /**
   * Escape a string for safe use in shell arguments.
   * Wraps in single quotes and escapes any embedded single quotes.
   * @private
   */
  _shellEscape(str) {
    if (typeof str !== 'string') str = String(str);
    return "'" + str.replace(/'/g, "'\\''") + "'";
  }

  _buildPostizCommand(params) {
    const { integrationId, mediaUrls, caption, title, schedule, settings, draft } = params;
    
    const args = [
      'postiz', 'posts:create',
      '-c', this._shellEscape(caption),
      '-i', this._shellEscape(integrationId),
      '--title', this._shellEscape(title)
    ];
    
    if (mediaUrls.length > 0) {
      args.push('-m', this._shellEscape(mediaUrls.join(',')));
    }
    
    if (schedule) {
      args.push('-s', this._shellEscape(schedule));
    }
    
    if (draft) {
      args.push('--draft');
    }
    
    if (settings && Object.keys(settings).length > 0) {
      args.push('--settings', this._shellEscape(JSON.stringify(settings)));
    }
    
    return `POSTIZ_API_KEY=${this._shellEscape(this.postiz.apiKey)} ${args.join(' ')}`;
  }

  /**
   * Parse Postiz command output
   * @private
   */
  _parsePostizOutput(output) {
    // Parse the JSON response from Postiz CLI
    try {
      const lines = output.trim().split('\n');
      const jsonLine = lines.find(line => line.startsWith('{'));
      return jsonLine ? JSON.parse(jsonLine) : { success: true, output };
    } catch (error) {
      return { success: true, rawOutput: output };
    }
  }

  /**
   * Save post metadata for analytics
   * @private
   */
  async _savePostMetadata(content, result, postInfo) {
    const metadataDir = path.join(content.outputDir || '.', '.metadata');
    if (!fs.existsSync(metadataDir)) {
      fs.mkdirSync(metadataDir, { recursive: true });
    }
    
    const metadata = {
      content: {
        type: content.contentType,
        generator: content.generator,
        duration: content.duration
      },
      post: postInfo,
      result,
      timestamp: new Date().toISOString()
    };
    
    const filename = `post-${Date.now()}.json`;
    fs.writeFileSync(
      path.join(metadataDir, filename), 
      JSON.stringify(metadata, null, 2)
    );
  }

  /**
   * Get next optimal posting time
   * @private
   */
  _getNextOptimalTime(optimalHours) {
    const now = new Date();
    const currentHour = now.getHours();
    
    // Find next optimal hour today or tomorrow
    const nextHour = optimalHours.find(h => h > currentHour) || optimalHours[0];
    
    const nextTime = new Date(now);
    if (nextHour <= currentHour) {
      nextTime.setDate(nextTime.getDate() + 1); // Tomorrow
    }
    nextTime.setHours(nextHour, 0, 0, 0);
    
    return nextTime;
  }
}

module.exports = Publisher;

path: core/ViralOptimizer.js
content: /**
 * Viral Optimizer
 * 
 * Data-driven content optimization engine based on TikTok virality research (Feb 2026).
 * Integrates directly into the content pipeline to maximize reach and engagement.
 * 
 * Key research findings baked in:
 * - Completion rate 80%+ = 5x reach increase
 * - Save-to-view ratio 15%+ = high-value content
 * - Share rate 8%+ = viral potential
 * - Videos with music = 98% more views
 * - 3-4 posts/week optimal (NOT 3/day)
 * - Carousels get 3x more saves, 40% longer dwell time
 * - First 3 seconds determine everything
 */

const fs = require('fs');
const path = require('path');

class ViralOptimizer {
  constructor(config) {
    this.config = config;
    
    // Research-backed thresholds
    this.thresholds = {
      completionRate: { target: 0.80, viral: 0.90, poor: 0.40 },
      saveToViewRatio: { target: 0.15, viral: 0.25, poor: 0.03 },
      shareRate: { target: 0.08, viral: 0.15, poor: 0.02 },
      commentRate: { target: 0.05, viral: 0.10, poor: 0.01 },
      profileVisitRate: { target: 0.12, viral: 0.20, poor: 0.03 },
      followerConversion: { target: 0.08, viral: 0.15, poor: 0.02 }
    };

    // Optimal posting schedule (research-backed)
    this.optimalSchedule = {
      postsPerWeek: { min: 3, optimal: 4, max: 5 },
      // New accounts: daily for first 30 days
      newAccountDailyDays: 30,
      bestDays: ['wednesday', 'tuesday', 'thursday'],
      bestTimes: {
        tuesday: ['17:00'],
        wednesday: ['14:00', '15:00', '16:00', '17:00'],
        thursday: ['15:00', '16:00', '17:00'],
        // Fallback for other days
        default: ['12:00', '18:00']
      },
      // CRITICAL: 3x/day HURTS reach. Research says quality > quantity
      maxPerDay: 1,
      newAccountMaxPerDay: 2
    };
  }

  /**
   * Optimize content before posting
   * Validates against viral mechanics and suggests improvements
   */
  optimizeContent(content, contentType) {
    const issues = [];
    const suggestions = [];
    const score = { hook: 0, structure: 0, engagement: 0, total: 0 };

    // 1. Hook analysis (first slide / first 3 seconds)
    const hookAnalysis = this._analyzeHook(content);
    score.hook = hookAnalysis.score;
    issues.push(...hookAnalysis.issues);
    suggestions.push(...hookAnalysis.suggestions);

    // 2. Content structure analysis
    const structureAnalysis = this._analyzeStructure(content, contentType);
    score.structure = structureAnalysis.score;
    issues.push(...structureAnalysis.issues);
    suggestions.push(...structureAnalysis.suggestions);

    // 3. Engagement potential analysis
    const engagementAnalysis = this._analyzeEngagementPotential(content);
    score.engagement = engagementAnalysis.score;
    issues.push(...engagementAnalysis.issues);
    suggestions.push(...engagementAnalysis.suggestions);

    // Overall viral potential score (0-100)
    score.total = Math.round((score.hook * 0.4 + score.structure * 0.3 + score.engagement * 0.3));

    return {
      score,
      issues,
      suggestions,
      verdict: this._getVerdict(score.total),
      shouldPost: score.total >= 50
    };
  }

  /**
   * Generate optimized hook based on content type and proven patterns
   */
  generateOptimizedHook(contentType, context = {}) {
    const hookPatterns = this._getProvenHookPatterns();
    const nicheHooks = hookPatterns[contentType] || hookPatterns.default;

    // Select hook pattern based on rotation strategy
    const pattern = this._selectHookPattern(nicheHooks, context);

    // Apply viral mechanics
    return {
      text: this._applyPlaceholders(pattern.template, context),
      type: pattern.type,
      expectedPerformance: pattern.avgEngagement,
      slideText: pattern.slideText ? this._applyPlaceholders(pattern.slideText, context) : null
    };
  }

  /**
   * Generate optimized caption with hashtags
   */
  generateOptimizedCaption(hook, contentType, context = {}) {
    const caption = [];

    // Hook text (keep short — TikTok truncates at ~150 chars visible)
    caption.push(hook);

    // CTA — research shows specific questions > generic
    const cta = this._getOptimalCTA(contentType, context);
    if (cta) caption.push(cta);

    // Hashtags — mix of niche + discovery
    const hashtags = this._getOptimalHashtags(contentType);
    caption.push(hashtags.join(' '));

    return caption.join('\n\n');
  }

  /**
   * Get optimal posting time for next post
   */
  getNextPostingSlot(accountAge = 30, recentPosts = []) {
    const isNewAccount = accountAge < this.optimalSchedule.newAccountDailyDays;
    const maxPerDay = isNewAccount ? 
      this.optimalSchedule.newAccountMaxPerDay : 
      this.optimalSchedule.maxPerDay;

    const now = new Date();
    const today = now.toISOString().split('T')[0];
    
    // Count posts today
    const postsToday = recentPosts.filter(p => 
      p.publishDate?.startsWith(today)
    ).length;

    if (postsToday >= maxPerDay) {
      // Schedule for tomorrow
      return this._getNextDayOptimalTime(now);
    }

    // Find next optimal time today
    return this._getNextOptimalTimeToday(now);
  }

  /**
   * Analyze post performance against viral thresholds
   */
  analyzePerformance(postMetrics) {
    const analysis = {
      metrics: {},
      diagnosis: '',
      actions: []
    };

    // Map metrics against thresholds
    for (const [metric, thresholds] of Object.entries(this.thresholds)) {
      const value = postMetrics[metric] || 0;
      let status = 'poor';
      
      if (value >= thresholds.viral) status = 'viral';
      else if (value >= thresholds.target) status = 'good';
      else if (value >= thresholds.poor) status = 'ok';

      analysis.metrics[metric] = { value, status, target: thresholds.target };
    }

    // Diagnostic matrix (from research)
    const views = postMetrics.views || 0;
    const engagement = (postMetrics.likes + postMetrics.comments + postMetrics.shares) / Math.max(views, 1);
    const saves = (postMetrics.saves || 0) / Math.max(views, 1);

    if (views > 1000 && engagement > 0.05) {
      analysis.diagnosis = 'SCALE';
      analysis.actions.push('Create 3 variations of this content');
      analysis.actions.push('Test same hook with different visuals');
    } else if (views > 1000 && engagement < 0.03) {
      analysis.diagnosis = 'FIX_CTA';
      analysis.actions.push('Hook is working — add stronger call-to-action');
      analysis.actions.push('Add opinion-split or challenge in caption');
    } else if (views < 500 && saves > 0.10) {
      analysis.diagnosis = 'FIX_HOOK';
      analysis.actions.push('Content converts — needs better opening hook');
      analysis.actions.push('Test with trending audio');
      analysis.actions.push('Stronger first-slide text overlay');
    } else if (views < 500 && engagement < 0.03) {
      analysis.diagnosis = 'FULL_RESET';
      analysis.actions.push('Try radically different content format');
      analysis.actions.push('Research what top creators in niche are doing');
      analysis.actions.push('Test different posting time');
    }

    return analysis;
  }

  /**
   * Get engagement-driving CTA for content type
   * Research: specific questions > generic "what do you think?"
   */
  _getOptimalCTA(contentType, context = {}) {
    const ctaPatterns = {
      'remember-this-card': [
        'What rating would you give this card? 👇',
        'Drop your best memory with this card 💬',
        'Who was YOUR favourite in {game} {year}? 🤔',
        'Save this if you used {player} 🔖'
      ],
      'card-evolution': [
        'Which version was the best? Comment below 👇',
        'Send this to someone who remembers 📤',
        'Save this evolution — it\'s insane 🔖'
      ],
      'cheat-code': [
        'Was {player} the biggest cheat code? 🎮',
        'Tag someone who abused this card 😂',
        'Like if you scored sweaty goals with {player} ⚽'
      ],
      'luxury-showcase': [
        'Would you live here? Yes or no 👇',
        'Save this for your dream home board 🏡',
        'What would you change about this place? 🤔'
      ],
      'coin-explainer': [
        'Save this before your next investment 📌',
        'Which crypto are you most bullish on? 👇',
        'Send this to someone who needs to understand {coin} 📤'
      ],
      default: [
        'Thoughts? 👇',
        'Save for later 🔖',
        'Share with someone who needs this 📤'
      ]
    };

    const ctas = ctaPatterns[contentType] || ctaPatterns.default;
    const cta = ctas[Math.floor(Math.random() * ctas.length)];
    return this._applyPlaceholders(cta, context);
  }

  /**
   * Get optimal hashtag mix
   * Research: niche community hashtags > broad hashtags
   */
  _getOptimalHashtags(contentType) {
    // Strategy: 2-3 niche community hashtags + 1-2 discovery hashtags
    const nicheHashtags = this.config.content?.hashtagSets?.[contentType] || 
                          this.config.content?.hashtagSets?.default || [];
    
    const discoveryHashtags = ['#fyp', '#viral'];
    
    // Max 5 hashtags total (research shows diminishing returns beyond this)
    const combined = [...nicheHashtags.slice(0, 3), ...discoveryHashtags.slice(0, 2)];
    return [...new Set(combined)].slice(0, 5);
  }

  /**
   * Proven hook patterns from research
   */
  _getProvenHookPatterns() {
    return {
      // Gaming nostalgia hooks
      'remember-this-card': [
        { template: 'Remember this card? 🔥', type: 'nostalgia', avgEngagement: 0.06, slideText: '{player} — the one that got away' },
        { template: 'Only real ones remember {player} in {game} {year}', type: 'gatekeeping', avgEngagement: 0.08, slideText: 'If you know, you know 🐐' },
        { template: 'This card was different 💯', type: 'mystery', avgEngagement: 0.05, slideText: 'Wait for it...' },
        { template: 'POV: You just packed {player} in {game} {year} 😱', type: 'pov', avgEngagement: 0.07, slideText: 'That feeling >>> everything' },
        { template: 'Everyone used {player}. Nobody talks about it.', type: 'contradiction', avgEngagement: 0.09, slideText: 'The most OP card nobody mentions' }
      ],
      'card-evolution': [
        { template: '{player}\'s glow up across {game} 📈', type: 'progression', avgEngagement: 0.06 },
        { template: 'Watch {player} become a monster 💪', type: 'transformation', avgEngagement: 0.07 }
      ],
      'cheat-code': [
        { template: '{player} was literally a cheat code 🎮', type: 'exaggeration', avgEngagement: 0.07 },
        { template: 'This card broke the game and nobody stopped it', type: 'controversy', avgEngagement: 0.09 },
        { template: 'If you used {player} in {game} {year}, you had no skill 😂', type: 'challenge', avgEngagement: 0.11 }
      ],
      'luxury-showcase': [
        { template: '£{price} gets you this in {location} 🏡', type: 'price-anchor', avgEngagement: 0.06 },
        { template: 'Would you live here? 👀', type: 'question', avgEngagement: 0.08 },
        { template: 'This {location} home is insane 🤯', type: 'reaction', avgEngagement: 0.05 }
      ],
      'coin-explainer': [
        { template: 'Let me explain {coin} like you\'re 5 🧵', type: 'simplification', avgEngagement: 0.06 },
        { template: 'This one mistake costs people £10K in {coin} 💸', type: 'fear', avgEngagement: 0.09 },
        { template: 'What {coin} actually does (no jargon) 💡', type: 'clarity', avgEngagement: 0.07 }
      ],
      default: [
        { template: 'Wait for it... 👀', type: 'curiosity', avgEngagement: 0.04 },
        { template: 'Nobody talks about this 🤫', type: 'exclusivity', avgEngagement: 0.06 }
      ]
    };
  }

  _analyzeHook(content) {
    const result = { score: 50, issues: [], suggestions: [] };
    
    const hook = content.hook || content.caption || '';
    
    // Check hook length (research: short hooks win)
    if (hook.length > 100) {
      result.issues.push('Hook too long — keep under 100 chars for first-slide text');
      result.score -= 15;
    }
    
    // Check for proven patterns
    const hasQuestion = hook.includes('?');
    const hasEmoji = /[\u{1F600}-\u{1F64F}\u{1F300}-\u{1F5FF}\u{1F680}-\u{1F6FF}]/u.test(hook);
    const hasNumbers = /\d/.test(hook);
    const hasPowerWords = /remember|secret|never|always|only|best|worst|insane|crazy|cheat|broken/i.test(hook);
    
    if (hasQuestion) result.score += 10;
    if (hasEmoji) result.score += 5;
    if (hasPowerWords) result.score += 15;
    if (hasNumbers) result.score += 5;
    
    if (!hasEmoji) result.suggestions.push('Add emoji — increases visual stopping power');
    if (!hasPowerWords) result.suggestions.push('Use power words (remember, secret, insane, cheat code)');
    if (!hasQuestion && !hasPowerWords) result.suggestions.push('Consider question format or contradiction hook');

    return result;
  }

  _analyzeStructure(content, contentType) {
    const result = { score: 60, issues: [], suggestions: [] };
    
    const slideCount = content.slides?.length || content.media?.length || 0;
    
    // Research: 5-7 slides optimal for carousels
    if (slideCount >= 5 && slideCount <= 7) {
      result.score += 20;
    } else if (slideCount < 5) {
      result.issues.push(`Only ${slideCount} slides — research shows 5-7 is optimal`);
      result.score -= 10;
    } else if (slideCount > 8) {
      result.suggestions.push('Consider trimming — engagement drops after 7 slides');
      result.score -= 5;
    }

    // Check for music/audio
    if (!content.audio && !content.music) {
      result.issues.push('No audio attached — videos with music get 98% more views');
      result.score -= 20;
    }

    return result;
  }

  _analyzeEngagementPotential(content) {
    const result = { score: 50, issues: [], suggestions: [] };
    
    const caption = content.caption || '';
    
    // Check for CTA
    const hasCTA = /\?|comment|save|share|tag|send|drop|tell|rate/i.test(caption);
    if (hasCTA) {
      result.score += 20;
    } else {
      result.suggestions.push('Add a specific CTA — "Rate this 1-10" outperforms "What do you think?"');
    }
    
    // Check for opinion-split potential (drives comments)
    const hasOpinionSplit = /or|vs|better|worse|team|choose/i.test(caption);
    if (hasOpinionSplit) result.score += 15;
    
    // Check hashtag count (research: max 5 effective)
    const hashtagCount = (caption.match(/#/g) || []).length;
    if (hashtagCount > 5) {
      result.suggestions.push('Reduce hashtags to 5 max — diminishing returns beyond that');
      result.score -= 5;
    } else if (hashtagCount >= 3 && hashtagCount <= 5) {
      result.score += 10;
    }

    return result;
  }

  _getVerdict(score) {
    if (score >= 80) return '🔥 HIGH VIRAL POTENTIAL — Post immediately';
    if (score >= 65) return '✅ GOOD — Ready to post';
    if (score >= 50) return '⚠️ DECENT — Consider optimizing before posting';
    if (score >= 35) return '🔧 NEEDS WORK — Apply suggestions before posting';
    return '❌ LOW POTENTIAL — Rethink approach';
  }

  _selectHookPattern(patterns, context) {
    // Weight selection by average engagement
    const totalWeight = patterns.reduce((sum, p) => sum + p.avgEngagement, 0);
    let random = Math.random() * totalWeight;
    
    for (const pattern of patterns) {
      random -= pattern.avgEngagement;
      if (random <= 0) return pattern;
    }
    
    return patterns[0];
  }

  _applyPlaceholders(text, context) {
    return text.replace(/\{(\w+)\}/g, (match, key) => context[key] || match);
  }

  _getNextOptimalTimeToday(now) {
    const dayName = now.toLocaleDateString('en-US', { weekday: 'long' }).toLowerCase();
    const times = this.optimalSchedule.bestTimes[dayName] || this.optimalSchedule.bestTimes.default;
    const currentTime = `${String(now.getHours()).padStart(2, '0')}:${String(now.getMinutes()).padStart(2, '0')}`;
    
    const nextTime = times.find(t => t > currentTime);
    if (nextTime) {
      const [h, m] = nextTime.split(':');
      const slot = new Date(now);
      slot.setHours(parseInt(h), parseInt(m), 0, 0);
      return slot;
    }
    
    return this._getNextDayOptimalTime(now);
  }

  _getNextDayOptimalTime(now) {
    const tomorrow = new Date(now);
    tomorrow.setDate(tomorrow.getDate() + 1);
    const dayName = tomorrow.toLocaleDateString('en-US', { weekday: 'long' }).toLowerCase();
    const times = this.optimalSchedule.bestTimes[dayName] || this.optimalSchedule.bestTimes.default;
    
    const [h, m] = times[0].split(':');
    tomorrow.setHours(parseInt(h), parseInt(m), 0, 0);
    return tomorrow;
  }
}

module.exports = ViralOptimizer;

path: package.json
content: {
  "name": "tiktok-content-pipeline",
  "version": "1.0.0",
  "description": "Automated TikTok carousel content pipeline with smart scheduling and analytics-driven optimization",
  "main": "cli.js",
  "bin": {
    "content-pipeline": "./cli.js"
  },
  "dependencies": {
    "canvas": "^3.2.1",
    "cheerio": "^1.2.0",
    "sharp": "^0.34.5"
  },
  "keywords": ["tiktok", "content-pipeline", "carousel", "postiz", "automation", "openclaw-skill"],
  "license": "MIT"
}


path: templates/example-nostalgia/README.md
content: # Creating Your Own Template

Templates define what content your pipeline produces. Each template is a folder containing:

- **`template.json`** — Content types, hook strategies, hashtag strategy, slide design specs
- **`config.json`** — Full account config (hooks, hashtags, posting settings) — used when creating accounts with `cli.js create`
- **`generator.js`** — Content generation logic (extends `ContentGenerator`)

## How to Create a Template

### 1. Fork this example

```bash
cp -r templates/example-nostalgia templates/your-niche
```

### 2. Edit `template.json`

Define your content types. Each type needs:
- A description
- Number of slides
- Slide structure (what each slide's purpose is)

### 3. Create `config.json`

Copy from `config.example.json` and customize:
- `content.types` — your content types with descriptions
- `hooks` — arrays of hook texts per content type (use `{placeholder}` for dynamic values)
- `questionHooks` — question-format hooks that drive comments
- `content.hashtagSets` — hashtags per content type
- `searchKeywords` — SEO keywords overlaid on slides
- `ctaSlides` — call-to-action texts for the final slide

### 4. Create `generator.js`

Extend `ContentGenerator` from `../../core/ContentGenerator.js`:

```javascript
const ContentGenerator = require('../../core/ContentGenerator');
const sharp = require('sharp');
const fs = require('fs');
const path = require('path');

class YourNicheGenerator extends ContentGenerator {
  getSupportedTypes() {
    return Object.keys(this.config.content.types);
  }

  async _generateContent(contentType, options, outputDir) {
    this._ensureOutputDir(outputDir);
    
    const slideCount = this.getContentConfig(contentType).slides || 6;
    const slide1Hook = this.getSlide1Hook(contentType, options);
    const slides = [];

    for (let i = 1; i <= slideCount; i++) {
      const slidePath = path.join(outputDir, `slide${i}.png`);
      
      // Generate slide image using sharp
      const svg = `<svg width="1024" height="1536">
        <rect width="100%" height="100%" fill="#0d1117"/>
        <text x="512" y="768" text-anchor="middle" fill="white" font-size="48">
          ${i === 1 ? slide1Hook.text : `Slide ${i}`}
        </text>
      </svg>`;
      
      await sharp(Buffer.from(svg)).png().toFile(slidePath);
      slides.push(slidePath);

      // Add keyword overlay
      const keywords = this.getSearchKeywordOverlay(contentType, i);
      if (keywords) {
        // Use ImageMagick or sharp to add keyword text
      }
    }

    const hookText = this.generateHook(contentType, options);
    const hashtags = this.getHashtags(contentType);
    const caption = this.buildCaption(hookText, slide1Hook) + '\n\n' + hashtags.join(' ');

    return { slides, hook: hookText, caption, outputDir };
  }
}

module.exports = YourNicheGenerator;
```

### 5. Test it

```bash
node cli.js create test-account --template your-niche
node cli.js generate test-account --type your-content-type
```

## Hook Tips

From the research:
- **Keep hooks under 100 characters** for slide text
- **Use power words**: remember, secret, insane, cheat code, nobody, everyone
- **Questions drive comments** — the framework uses questionHooks 50% of the time on slide 1
- **Contradiction hooks** ("Everyone thinks X, but actually...") consistently outperform other types
- **Add emoji** — increases visual stopping power in the feed


path: templates/example-nostalgia/template.json
content: {
  "name": "Example Nostalgia Template",
  "description": "A starter template for nostalgia-themed TikTok carousel content. Fork this and adapt for your niche.",
  "niche": "nostalgia",
  "contentTypes": {
    "showcase": {
      "description": "Showcase a nostalgic item/moment with a hook slide, content slides, and CTA",
      "slides": 6,
      "structure": [
        { "slide": 1, "purpose": "Hook — grab attention with bold text + striking image" },
        { "slide": 2, "purpose": "Context — set the scene, introduce the subject" },
        { "slide": 3, "purpose": "Detail — key information or highlight" },
        { "slide": 4, "purpose": "Detail — another angle or fact" },
        { "slide": 5, "purpose": "Payoff — the emotional peak or reveal" },
        { "slide": 6, "purpose": "CTA — save/share/comment prompt" }
      ]
    },
    "evolution": {
      "description": "Show how something changed over time",
      "slides": 6,
      "structure": [
        { "slide": 1, "purpose": "Hook — 'Watch the evolution of...'" },
        { "slide": 2, "purpose": "Early version" },
        { "slide": 3, "purpose": "Mid version" },
        { "slide": 4, "purpose": "Later version" },
        { "slide": 5, "purpose": "Current/final version" },
        { "slide": 6, "purpose": "CTA — 'Which era was the best?'" }
      ]
    },
    "top-picks": {
      "description": "Curated list of top items in your niche",
      "slides": 6,
      "structure": [
        { "slide": 1, "purpose": "Hook — 'Top 5 [things] you forgot about'" },
        { "slide": 2, "purpose": "Pick #5" },
        { "slide": 3, "purpose": "Pick #4 and #3" },
        { "slide": 4, "purpose": "Pick #2" },
        { "slide": 5, "purpose": "Pick #1 (the best)" },
        { "slide": 6, "purpose": "CTA — 'What would YOUR #1 be?'" }
      ]
    }
  },
  "hookStrategies": [
    { "type": "nostalgia", "pattern": "Remember {item}? 🔥", "effectiveness": "high" },
    { "type": "gatekeeping", "pattern": "Only real ones remember {item}", "effectiveness": "high" },
    { "type": "challenge", "pattern": "Name a better {category}. I'll wait.", "effectiveness": "very-high" },
    { "type": "pov", "pattern": "POV: You just discovered {item} 😱", "effectiveness": "high" },
    { "type": "contradiction", "pattern": "Everyone forgot about {item}. Nobody talks about it.", "effectiveness": "very-high" },
    { "type": "question", "pattern": "Was {item} the best {category} ever? 🤔", "effectiveness": "high" }
  ],
  "hashtagStrategy": {
    "niche": ["#nostalgia", "#throwback", "#remember"],
    "discovery": ["#fyp", "#viral"],
    "maxTotal": 5,
    "note": "2-3 niche hashtags + 1-2 discovery hashtags. Diminishing returns beyond 5."
  },
  "slideDesign": {
    "size": { "width": 1024, "height": 1536 },
    "background": { "style": "dark-gradient", "colors": ["#0d1117", "#1a1a2e"] },
    "text": { "font": "bold sans-serif", "color": "#ffffff", "hookSize": "large", "bodySize": "medium" },
    "notes": "High contrast text is essential — TikTok users scroll fast. Use 60pt+ for hook text."
  }
}


Security scan verdicts — ClawScan: clean | static analysis: suspicious | VirusTotal: suspicious | SkillSpector: suspicious (HIGH)