Initial commit with translated description

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

849
SKILL.md Normal file
View File

@@ -0,0 +1,849 @@
---
name: tiktok-app-marketing
description: "为任何应用或产品自动化TikTok幻灯片营销。"
---
# TikTok App Marketing
Automate your entire TikTok slideshow marketing pipeline: generate → overlay → post → track → iterate.
**Proven results:** 7 million views on the viral X article, 1M+ TikTok views, $670/month MRR — all from an AI agent running on an old gaming PC.
## Prerequisites
This skill does NOT bundle any dependencies. Your AI agent will need to research and install the following based on your setup. Tell your agent what you're working with and it will figure out the rest.
### Required
- **Node.js** (v18+) — all scripts run on Node. Your agent should verify this is installed and install it if not.
- **node-canvas** (`npm install canvas`) — used for adding text overlays to slide images. This is a native module that may need build tools (Python, make, C++ compiler) on some systems. Your agent should research the install requirements for your OS.
- **Postiz** — this is the backbone of the whole system. Postiz handles posting to TikTok (and 28+ other platforms), but more importantly, it provides the **analytics API** that powers the daily feedback loop. Without Postiz, the agent can post but can't track what's working — and the feedback loop is what makes this skill actually grow your account instead of just posting blindly. Sign up at [postiz.pro/oliverhenry](https://postiz.pro/oliverhenry).
### Image Generation (pick one)
You choose what generates your images. Your agent should research the API docs for whichever you pick:
- **OpenAI** — `gpt-image-1.5` **(ALWAYS 1.5, never 1)**. Needs an OpenAI API key. Best for realistic photo-style images. This is what Larry uses and what we strongly recommend.
- **Stability AI** — Stable Diffusion XL and newer. Needs a Stability AI API key. Good for stylized/artistic images.
- **Replicate** — run any open-source model (Flux, SDXL, etc.). Needs a Replicate API token. Most flexible.
- **Local** — bring your own images. No API needed. Place images in the output directory and the script skips generation.
### Conversion Tracking (optional but recommended for mobile apps)
- **RevenueCat** — this is what completes the intelligence loop. Postiz tells you which posts get views. RevenueCat tells you which posts drive **paying users**. Combined, the agent can distinguish between a viral post that makes no money and a modest post that actually converts — and optimize accordingly. Install the RevenueCat skill from ClaWHub (`clawhub install revenuecat`) for full API access to subscribers, MRR, trials, churn, and revenue. There's also a **RevenueCat MCP** for programmatic control over products and offerings from your agent/IDE.
### Cross-Posting (optional, recommended)
Postiz supports cross-posting to Instagram Reels, YouTube Shorts, Threads, Facebook, LinkedIn, and 20+ more platforms simultaneously. Your agent should research which platforms fit your audience and connect them in Postiz. Same content, different algorithms, more reach.
## First Run — Onboarding
When this skill is first loaded, IMMEDIATELY start a conversation with the user. Don't dump a checklist — talk to them like a human marketing partner would. The flow below is a guide, not a script. Be natural. Ask one or two things at a time. React to what they say. Build on their answers.
**Important:** Use `scripts/onboarding.js --validate` at the end to confirm the config is complete.
### Phase 0: TikTok Account Warmup (CRITICAL — Don't Skip This)
Before anything else, check if the user already has a TikTok account with posting history. If they're creating a fresh account, they MUST warm it up first or TikTok will treat them like a bot and throttle their reach from day one.
Explain this naturally:
> "Quick question before we dive in — do you already have a TikTok account you've been using, or are we starting fresh? If it's new, we need to warm it up first. TikTok's algorithm watches how new accounts behave, and if you go straight from creating an account to posting AI slideshows, it flags you as a bot and kills your reach."
**If the account is new or barely used, walk them through this:**
The goal is to use TikTok like a normal person for **7-14 days** before posting anything. Spend **30-60 minutes a day** on the app:
- **Scroll the For You page naturally.** Watch some videos all the way through. Skip others halfway. Don't watch every single one to the end — that's not how real people scroll.
- **Like sparingly.** Maybe 1 in 10 videos. Don't like everything — that's bot behaviour. Only like things you'd genuinely engage with in your niche.
- **Follow accounts in your niche.** If they're promoting a fitness app, follow fitness creators. Room design? Interior design accounts. This trains the algorithm to understand what the account is about.
- **Watch niche content intentionally.** This is the most important part. TikTok learns what you engage with and starts showing you more of it. You want the For You page dominated by content similar to what you'll be posting.
- **Leave a few genuine comments.** Not spam. Real reactions. A few per session.
- **Maybe post 1-2 casual videos.** Nothing promotional. Just normal content that shows TikTok there's a real person behind the account.
**The signal to look for:** When they open TikTok and almost every video on their For You page is in their niche, the account is warmed up. The algorithm understands them. NOW they can start posting.
Tell the user: "I know two weeks feels like wasted time, but accounts that skip warmup consistently get 80-90% less reach on their first posts. Do the warmup. It's the difference between your first post getting 200 views and 20,000."
**If the account is already active and established,** skip this entirely and move to Phase 1.
### Phase 1: Get to Know Their App (Conversational)
Start casual. Something like:
> "Hey! Let's get your TikTok marketing set up. First — tell me about your app. What's it called, what does it do?"
Then FOLLOW UP based on what they say. Don't ask all 9 questions at once. Pull the thread:
- They mention what it does → ask who it's for ("Who's your ideal user?")
- They describe the audience → ask about the pain point ("What's the main problem it solves for them?")
- They explain the problem → ask what makes them different ("What makes yours stand out vs alternatives?")
- Get the App Store / website link naturally ("Can you drop me the link?")
- Determine category (home/beauty/fitness/productivity/food/other) — often inferable
**Don't ask for "brand guidelines" robotically.** Instead: "Do you have any existing content or a vibe you're going for? Or are we starting fresh?"
**Then ask about their app and monetization:**
> "Is this a mobile app? And do you use RevenueCat (or any subscription/in-app purchase system) to handle payments?"
This is critical because it determines whether we can close the full feedback loop. If they have a mobile app with RevenueCat:
- **Tell them about the RevenueCat skill on ClawHub** (`clawhub install revenuecat`). It gives full API access to subscribers, MRR, trials, churn, revenue, and transactions. Don't auto-install — just let them know it exists and what it unlocks, and they can install it if they want.
- **Explain why it matters:** Without RevenueCat data, the skill can only optimize for views (vanity metrics). With it, the skill optimizes for actual paying users. The difference is massive. A post with 200K views and zero conversions is worthless. A post with 5K views and 10 paid subscribers is gold. You can only tell the difference with RevenueCat connected.
If they don't use RevenueCat but have another subscription system, note it and work with what's available. If it's not a mobile app (e.g. physical product, SaaS, service), skip RevenueCat but still track whatever conversion metric they have (website signups, purchases, leads).
Store everything in `tiktok-marketing/app-profile.json`.
### Phase 2: Competitor Research (Requires Browser Permission)
Before building any content strategy, research what competitors are doing on TikTok. This is critical — you need to know the landscape.
Ask the user:
> "Before we start creating content, I want to research what your competitors are doing on TikTok — what's getting views in your niche, what hooks they're using, what's working and what's not. Can I use the browser to look around TikTok and the App Store?"
**Wait for permission.** Then:
1. **Search TikTok** for the app's niche (e.g. "interior design app", "lip filler filter", "fitness transformation app")
2. **Find 3-5 competitor accounts** posting similar content
3. **Analyze their top-performing content:**
- What hooks are they using?
- What slide format? (before/after, listicle, POV, tutorial)
- How many views on their best vs average posts?
- What's their posting frequency?
- What CTAs are they using?
- What music/sounds are trending in the niche?
4. **Check the App Store** for the app's category — look at competitor apps, their screenshots, descriptions, ratings
5. **Compile findings** into `tiktok-marketing/competitor-research.json`:
```json
{
"researchDate": "2026-02-16",
"competitors": [
{
"name": "CompetitorApp",
"tiktokHandle": "@competitor",
"followers": 50000,
"topHooks": ["hook 1", "hook 2"],
"avgViews": 15000,
"bestVideo": { "views": 500000, "hook": "..." },
"format": "before-after slideshows",
"postingFrequency": "daily",
"cta": "link in bio",
"notes": "Strong at X, weak at Y"
}
],
"nicheInsights": {
"trendingSounds": [],
"commonFormats": [],
"gapOpportunities": "What competitors AREN'T doing that we could",
"avoidPatterns": "What's clearly not working"
}
}
```
6. **Share findings with the user** conversationally:
> "So I looked at what's out there. [Competitor A] is doing well with [format] — their best post got [X] views using [hook type]. But I noticed nobody's really doing [gap]. That's our angle."
This research directly informs hook generation and content strategy. Reference it when creating posts.
### Phase 3: Content Format & Image Generation
First, ask about format:
> "Do you want to do slideshows (photo carousels) or video? Slideshows are what Larry uses and what this skill is built around — TikTok's data shows they get 2.9x more comments and 2.6x more shares than video, and they're much easier for AI to generate consistently. That said, if you want to try video, the skill supports it but it hasn't been battle-tested like slideshows have. Your call."
Store their choice as `format: "slideshow"` or `format: "video"` in config. If they pick video, note that the text overlay, 6-slide structure, and prompt templates are designed for slideshows. Video will require more experimentation and the agent should be upfront about that.
**For slideshows (recommended):**
Ask naturally:
> "For the slideshows, we need images. I'd strongly recommend OpenAI's gpt-image-1.5 — it's what Larry uses and it produces images that genuinely look like someone took them on their phone. It's the difference between 'obviously AI' and 'wait, is that real?' You can also use Stability AI, Replicate, or bring your own images if you prefer."
**⚠️ If they pick OpenAI, make sure the model is set to `gpt-image-1.5` — NEVER `gpt-image-1`.** The difference in quality is massive. gpt-image-1 produces noticeably AI-looking images that people scroll past. gpt-image-1.5 produces photorealistic results that stop the scroll. This one setting can be the difference between 1K and 100K views.
If they're unsure, always recommend gpt-image-1.5. It's the proven choice.
Store in config as `imageGen` with provider, apiKey, and model.
**If they pick OpenAI**, mention the Batch API:
> "One thing worth knowing — OpenAI has a Batch API that's **50% cheaper** than real-time generation. Instead of generating slides on the spot, you submit them as a batch job and get results within 24 hours (usually much faster). It's perfect for pre-generating tomorrow's slides overnight. Same quality, half the cost. Want me to set that up?"
If they're interested, store `"useBatchAPI": true` in `imageGen` config. The generate script supports both modes — real-time for quick iterations, batch for scheduled daily content.
**Then — and this is critical — work through the image style with them.** Don't just use a generic prompt. Bad images = nobody watches. Ask these naturally, one or two at a time:
> "Now let's figure out what these images should actually look like. Do you want them to look like real photos someone took on their phone, or more like polished graphics or illustrations?"
Then based on their answer, dig deeper:
- **What's the subject?** "What are we actually showing? Rooms? Faces? Products? Before/after comparisons?"
- **What vibe?** "Cozy and warm? Clean and minimal? Luxurious? Think about what your audience relates to or aspires to."
- **Consistency:** "Should all 6 slides look like the same place or person? If yes — I need to lock down specific details so each slide doesn't look totally different."
- **Must-have elements?** "Anything that HAS to be in every image? A specific product? Certain furniture? A pet?"
Build the base prompt WITH them. A good base prompt looks like:
```
iPhone photo of a [specific room/scene], [specific style], [specific details].
Realistic lighting, natural colors, taken on iPhone 15 Pro.
No text, no watermarks, no logos.
[Consistency anchors: "same window on left wall", "same grey sofa", "wooden coffee table in center"]
```
**Save the agreed prompt style to config as `imageGen.basePrompt`** so every future post uses it.
**Key prompt rules (explain these as they come up, don't lecture):**
- "iPhone photo" + "realistic lighting" = looks real, not AI-generated
- Lock architecture/layout in EVERY slide prompt or each slide looks like a different place
- Include everyday objects (mugs, remotes, magazines) for lived-in feel
- For before/after: "before" = modern but tired, NOT ancient
- Portrait orientation (1024x1536) always — this is TikTok
- Extremely specific > vague ("small galley kitchen with white cabinets and a window above the sink" > "a kitchen")
**NEVER use generic prompts** like "a nice living room" or "a beautiful face" — they produce generic images that get scrolled past.
### Phase 4: Postiz Setup (ESSENTIAL — Powers the Entire Feedback Loop)
Postiz isn't just a posting tool — it's what makes the whole feedback loop work. Without it, you're posting blind. With it, you get:
- **Automated posting** to TikTok (and 28+ other platforms) via API
- **Per-post analytics** — views, likes, comments, shares for every post
- **Platform analytics** — follower growth, total engagement over time
- **Cross-posting** — same content to Instagram, YouTube, Threads simultaneously
This data is what feeds the daily analytics cron (see Phase 8). Without Postiz analytics, the agent can't tell you which hooks are working and which to drop.
Frame it naturally to the user:
> "So here's the key piece — we need Postiz to handle posting and analytics. It's what lets me track every post's performance and tell you exactly which hooks are driving views and which to drop. Without it, we're guessing. With it, I can run a daily report that shows you what's working and automatically suggest better hooks."
>
> "This skill is free and open source. If you want to support its development, signing up through this link is appreciated: [postiz.pro/oliverhenry](https://postiz.pro/oliverhenry)"
Walk them through connecting step by step:
1. **Sign up at [postiz.pro/oliverhenry](https://postiz.pro/oliverhenry)** — create an account
2. **Connect TikTok** — this is the main one. Go to Integrations → Add TikTok → Authorize
3. **Note the TikTok integration ID** — you'll see it in the URL or integration settings. I need this to post and pull analytics
4. **Get the API key** — Settings → API → copy the key. This is how I talk to Postiz programmatically
5. **(Optional but recommended)** Connect Instagram, YouTube Shorts, Threads for cross-posting — same content, different algorithms, more reach for free
Explain the draft workflow:
> "One important thing — posts go to your TikTok inbox as drafts, not straight to your feed. Before you publish each one, add a trending sound from TikTok's sound library. Music is the single biggest factor in TikTok reach — silent slideshows get buried. It takes 30 seconds per post and makes a massive difference. This workflow helped us hit over 1 million TikTok views."
**Don't move on until Postiz is connected and the API key works.** Test it by hitting the platform analytics endpoint. If it returns data, you're good.
### Phase 5: Conversion Tracking (THE Intelligence Loop)
If they have a mobile app with RevenueCat (you should already know this from Phase 1), this is where the skill goes from "content automation" to "intelligent marketing system." This is the most important integration in the entire skill. Don't treat it as optional.
Explain WHY it matters:
> "So right now with Postiz, I can track which posts get views, likes, and comments. That's the top of the funnel. But views alone don't pay the bills — we need to know which posts actually drive paying subscribers."
>
> "This is where RevenueCat comes in. It tracks your subscribers, trials, MRR, churn — the actual revenue. When I combine TikTok analytics from Postiz with conversion data from RevenueCat, I can make genuinely intelligent decisions:"
>
> "If a post gets **50K views but zero conversions**, I know the hook is great but the CTA or app messaging needs work. If a post gets **2K views but 5 paid subscribers**, I know the content converts amazingly — we just need more eyeballs on it, so we fix the hook."
>
> "Without RevenueCat, I'm optimizing for vanity metrics. With it, I'm optimizing for revenue."
Walk them through setup step by step:
1. **Install the RevenueCat skill from ClaWHub:**
```
clawhub install revenuecat
```
This installs the `revenuecat` skill (v1.0.2+) which gives full API access to your RevenueCat project — metrics overview, customers, subscriptions, offerings, entitlements, transactions, and more. It includes reference docs for every API endpoint and a helper script (`scripts/rc-api.sh`) for direct API calls.
2. **Get your V2 secret API key** from the RevenueCat dashboard:
- Go to your RC project → Settings → API Keys
- Generate a **V2 secret key** (starts with `sk_`)
- ⚠️ This is a SECRET key — don't commit it to public repos
3. **Set the environment variable:**
```
export RC_API_KEY=sk_your_key_here
```
4. **Verify it works:** Run `./skills/revenuecat/scripts/rc-api.sh /projects` — should return your project details.
5. **Optional: RevenueCat MCP** — for programmatic control over products, offerings, and entitlements from your agent or IDE. Ask your agent to research setting this up.
**What RevenueCat gives the daily report:**
- `GET /projects/{id}/metrics/overview` → MRR, active subscribers, active trials, churn rate
- `GET /projects/{id}/transactions` → individual purchases with timestamps (for conversion attribution)
- The daily cron cross-references transaction timestamps with post publish times (24-72h window) to identify which posts drove which conversions
**The intelligence this unlocks:**
- "This hook got 50K views but zero conversions" → hook is great, CTA needs work
- "This hook got 5K views but 3 paid subscribers" → content converts amazingly, fix the hook for more reach
- "Conversions are consistently poor across all posts" → might be an app issue (onboarding, paywall, pricing) not a content issue — the skill flags this for investigation
**Without RevenueCat:** The loop still works on Postiz analytics (views/likes/comments). You can optimize for engagement. But you're flying blind on revenue. You'll know which posts get views but you won't know which posts make money.
**With RevenueCat:** You optimize for actual paying users. You can tell the difference between a viral post that makes nothing and a quiet post that drives $50 in subscriptions. This is the entire point of the feedback loop. Every decision the daily report makes is better with RevenueCat data.
If they don't use RevenueCat or don't have subscriptions, the skill still works but the feedback loop is limited to view-based optimization only.
### Phase 6: Content Strategy (Built from Research)
Using the competitor research AND the app profile, build an initial content strategy:
> "Based on what I found and what your app does, here's my plan for the first week..."
Present:
1. **3-5 hook ideas** tailored to their niche + competitor gaps
2. **Posting schedule** recommendation (default: 7:30am, 4:30pm, 9pm — their timezone)
3. **Which hook categories to test first** (reference what worked for competitors)
4. **Cross-posting plan** (which platforms, same or adapted content)
Save the strategy to `tiktok-marketing/strategy.json`.
### Phase 7: Set Up the Daily Analytics Cron
This is what makes the whole system self-improving. Set up a daily cron job that:
1. Pulls the last 3 days of post analytics from Postiz
2. Pulls conversion data from RevenueCat (if connected)
3. Cross-references views with conversions to diagnose what's working
4. Generates a report with specific recommendations
5. Suggests new hooks based on performance patterns
Explain to the user:
> "I'm going to set up a daily check that runs every morning. It looks at how your posts from the last 3 days performed — views, engagement, and if you've got RevenueCat connected, actual conversions. Then it tells you exactly what's working and what to change."
>
> "Posts typically peak at 24-48 hours, and conversions take up to 72 hours to attribute, so checking a 3-day window gives us the full picture."
**Set up the cron:**
Use the agent's cron system to schedule a daily analytics job. Run it every morning before the first post of the day (e.g. 7:00 AM in the user's timezone) so the report informs that day's content:
```
Schedule: daily at 07:00 (user's timezone)
Task: Run scripts/daily-report.js --config tiktok-marketing/config.json --days 3
Output: tiktok-marketing/reports/YYYY-MM-DD.md + message to user with summary
```
The daily report uses the diagnostic framework:
- **High views + High conversions** → Scale it — more of the same, test posting times
- **High views + Low conversions** → Hook works, CTA is broken — test new CTAs on slide 6, check app landing page
- **Low views + High conversions** → Content converts but nobody sees it — test radically different hooks, keep the CTA
- **Low views + Low conversions** → Full reset — new format, new audience angle, new hook categories
This is the intelligence layer. Without it, you're just posting and hoping. With it, every day's content is informed by data.
### Phase 8: Save Config & First Post
Store everything in `tiktok-marketing/config.json` (this is the source of truth for the entire pipeline):
```json
{
"app": {
"name": "AppName",
"description": "Detailed description",
"audience": "Target demographic",
"problem": "Pain point it solves",
"differentiator": "What makes it unique",
"appStoreUrl": "https://...",
"category": "home|beauty|fitness|productivity|food|other",
"isMobileApp": true
},
"imageGen": {
"provider": "openai",
"apiKey": "sk-...",
"model": "gpt-image-1.5"
},
"postiz": {
"apiKey": "your-postiz-key",
"integrationIds": {
"tiktok": "id-here",
"instagram": "id-here-optional",
"youtube": "id-here-optional"
}
},
"revenuecat": {
"enabled": false,
"v2SecretKey": "sk_...",
"projectId": "proj..."
},
"posting": {
"privacyLevel": "SELF_ONLY",
"schedule": ["07:30", "16:30", "21:00"],
"crossPost": ["instagram", "youtube"]
},
"competitors": "tiktok-marketing/competitor-research.json",
"strategy": "tiktok-marketing/strategy.json"
}
```
Then generate the **first test slideshow** — but set expectations:
> "Let's create our first slideshow. This is a TEST — we're dialing in the image style, not posting yet. I'll generate 6 slides and we'll look at them together. If the images look off, we tweak the prompts and try again. The goal is to get the look nailed down BEFORE we start posting."
**⚠️ THE REFINEMENT PROCESS IS PART OF THE SKILL:**
Getting the images right takes iteration. This is normal and expected. Walk the user through it:
1. **Generate a test set of 6 images** using the prompts you built together
2. **Show them the results** and ask: "How do these look? Too polished? Too dark? Wrong vibe? Wrong furniture?"
3. **Tweak based on feedback** — adjust the base prompt, regenerate
4. **Repeat until they're happy** — this might take 2-5 rounds, that's fine
5. **Lock the prompt style** once it looks right — save to config
Things to watch for and ask about:
- "Are these realistic enough or do they look AI-generated?"
- "Is the lighting right? Too bright? Too moody?"
- "Does this match what your users would actually relate to?"
- "Are the everyday details right? (furniture style, objects, layout)"
**You do NOT have to post anything you don't like.** The first few generations are purely for refining the prompt. Only start posting once the images consistently look good. The agent learns from each round — what works, what doesn't, what to emphasise in the prompt.
Once the style is locked in, THEN use the hook strategy from competitor research and their category (see [references/slide-structure.md](references/slide-structure.md)) and start the posting schedule.
---
## Core Workflow
### 1. Generate Slideshow Images
Use `scripts/generate-slides.js`:
```bash
node scripts/generate-slides.js --config tiktok-marketing/config.json --output tiktok-marketing/posts/YYYY-MM-DD-HHmm/ --prompts prompts.json
```
The script auto-routes to the correct provider based on `config.imageGen.provider`. Supports OpenAI, Stability AI, Replicate, or local images.
**⚠️ Timeout warning:** Generating 6 images takes 3-9 minutes total (30-90 seconds each for gpt-image-1.5). Set your exec timeout to at least **600 seconds (10 minutes)**. If you get `spawnSync ETIMEDOUT`, the exec timeout is too short. The script supports resume — if it fails partway, re-run it and completed slides will be skipped.
**Critical image rules (all providers):**
- ALWAYS portrait aspect ratio (1024x1536 or 9:16 equivalent) — fills TikTok screen
- Include "iPhone photo" and "realistic lighting" in prompts (for AI providers)
- ALL 6 slides share the EXACT same base description (only style/feature changes)
- Lock key elements across all slides (architecture, face shape, camera angle)
- See [references/slide-structure.md](references/slide-structure.md) for the 6-slide formula
### 2. Add Text Overlays
This step uses `node-canvas` to render text directly onto your slide images. This is how Larry produces slides that have hit **1M+ views on TikTok** — the text sizing, positioning, and styling are dialled in from hundreds of posts.
#### Setting Up node-canvas
Before you can add text overlays, your human needs to install `node-canvas`. Prompt them:
> "To add text overlays to the slides, I need a library called node-canvas. It renders text directly onto images with full control over sizing, positioning, and styling — this is what Larry uses for his viral TikTok slides.
>
> Can you run this in your terminal?"
>
> ```bash
> npm install canvas
> ```
>
> "If that fails, it's because node-canvas needs some system libraries. Here's what to install first:"
>
> **macOS:**
> ```bash
> brew install pkg-config cairo pango libpng jpeg giflib librsvg
> npm install canvas
> ```
>
> **Ubuntu/Debian:**
> ```bash
> sudo apt-get install build-essential libcairo2-dev libpango1.0-dev libjpeg-dev libgif-dev librsvg2-dev
> npm install canvas
> ```
>
> **Windows:**
> ```bash
> # node-canvas auto-downloads prebuilt binaries on Windows
> npm install canvas
> ```
>
> "Once installed, I can handle everything else — generating the overlays, sizing the text, positioning it perfectly. You won't need to touch this again."
**Don't skip this step.** Without node-canvas, the text overlays won't work. If installation fails, help them troubleshoot — it's usually a missing system library. Once it's installed once, it stays.
#### How Larry's Text Overlay Process Works
1. **Load the raw slide image** into a node-canvas
2. **Configure text settings** based on the text length for that specific slide
3. **Draw the text** with white fill and thick black outline
4. **Review the output** — check sizing, positioning, readability
5. **Adjust and re-render** if anything looks off
6. **Save the final image** once it looks right
**Exact code Larry uses:**
```javascript
const { createCanvas, loadImage } = require('canvas');
const fs = require('fs');
async function addOverlay(imagePath, text, outputPath) {
const img = await loadImage(imagePath);
const canvas = createCanvas(img.width, img.height);
const ctx = canvas.getContext('2d');
ctx.drawImage(img, 0, 0);
// ─── Adjust font size based on text length ───
const wordCount = text.split(/\s+/).length;
let fontSizePercent;
if (wordCount <= 5) fontSizePercent = 0.075; // Short: 75px on 1024w
else if (wordCount <= 12) fontSizePercent = 0.065; // Medium: 66px
else fontSizePercent = 0.050; // Long: 51px
const fontSize = Math.round(img.width * fontSizePercent);
const outlineWidth = Math.round(fontSize * 0.15);
const maxWidth = img.width * 0.75;
const lineHeight = fontSize * 1.3;
ctx.font = `bold ${fontSize}px Arial`;
ctx.textAlign = 'center';
ctx.textBaseline = 'top';
// ─── Word wrap ───
const lines = [];
const manualLines = text.split('\n');
for (const ml of manualLines) {
const words = ml.trim().split(/\s+/);
let current = '';
for (const word of words) {
const test = current ? `${current} ${word}` : word;
if (ctx.measureText(test).width <= maxWidth) {
current = test;
} else {
if (current) lines.push(current);
current = word;
}
}
if (current) lines.push(current);
}
// ─── Position: centered at ~28% from top ───
const totalHeight = lines.length * lineHeight;
const startY = (img.height * 0.28) - (totalHeight / 2);
const x = img.width / 2;
// ─── Draw each line ───
for (let i = 0; i < lines.length; i++) {
const y = startY + (i * lineHeight);
// Black outline
ctx.strokeStyle = '#000000';
ctx.lineWidth = outlineWidth;
ctx.lineJoin = 'round';
ctx.miterLimit = 2;
ctx.strokeText(lines[i], x, y);
// White fill
ctx.fillStyle = '#FFFFFF';
ctx.fillText(lines[i], x, y);
}
fs.writeFileSync(outputPath, canvas.toBuffer('image/png'));
}
```
**Key details that make Larry's slides look professional:**
- **Dynamic font sizing** — short text gets bigger (75px), long text gets smaller (51px). Every slide is optimized.
- **Word wrap** — respects manual `\n` breaks but also auto-wraps lines that exceed 75% width. No squashing.
- **Centered at 28% from top** — text block is vertically centered around this point, not pinned to it. Stays in the safe zone regardless of line count.
- **Thick outline** — 15% of font size. Makes text readable on ANY background.
- **Manual line breaks preferred** — use `\n` in your text for control. Keep lines to 4-6 words.
**Text content rules:**
- **REACTIONS not labels** — "Wait... this is actually nice??" not "Modern minimalist"
- **4-6 words per line** — short lines are scannable at a glance
- **3-4 lines per slide is ideal**
- **No emoji** — canvas can't render them reliably
- **Safe zones:** No text in bottom 20% (TikTok controls) or top 10% (status bar)
**The difference between OK slides and viral slides is in these details.** Larry's slides consistently hit 50K-150K+ views because the text is sized right, positioned right, and readable at a glance while scrolling.
**⚠️ LINE BREAKS ARE CRITICAL — Read This:**
The `texts.json` file must contain text with `\n` line breaks to control where lines wrap. If you pass a single long string without line breaks, the script will auto-wrap, but **manual breaks look much better** because you control the rhythm.
**Good (manual breaks, 4-6 words per line):**
```json
[
"I showed my landlord\nwhat AI thinks our\nkitchen should look like",
"She said you can't\nchange anything\nchallenge accepted",
"So I downloaded\nthis app and\ntook one photo",
"Wait... is this\nactually the same\nkitchen??",
"Okay I'm literally\nobsessed with\nthis one",
"Snugly showed me\nwhat's possible\nlink in bio"
]
```
**Bad (no breaks — will auto-wrap but looks worse):**
```json
[
"I showed my landlord what AI thinks our kitchen should look like",
...
]
```
**Rules for writing overlay text:**
1. **4-6 words per line MAX** — short lines are scannable at a glance
2. **Use `\n` to break lines** — gives you control over the rhythm
3. **3-4 lines per slide is ideal** — more lines are fine, they won't overflow
4. **Read it out loud** — each line should feel like a natural pause
5. **No emoji** — canvas can't render them, they'll show as blank
6. **REACTIONS not labels** — "Wait... this is nice??" not "Modern minimalist"
The script auto-wraps any line that exceeds 75% width as a safety net, but always prefer manual `\n` breaks for the best visual result.
### 3. Post to TikTok
Use `scripts/post-to-tiktok.js`:
```bash
node scripts/post-to-tiktok.js --config tiktok-marketing/config.json --dir tiktok-marketing/posts/YYYY-MM-DD-HHmm/ --caption "caption" --title "title"
```
### Why We Post as Drafts (SELF_ONLY) — Best Practice
Posts go to your TikTok inbox as drafts, NOT published directly. This is intentional and critical:
1. **Music is everything on TikTok.** Trending sounds massively boost reach. The algorithm favours posts using popular audio. An API can't pick the right trending sound — you need to browse TikTok's sound library and pick what's hot RIGHT NOW in your niche.
2. **You add the music manually**, then publish from your TikTok inbox. Takes 30 seconds per post.
3. **Posts without music get buried.** Silent slideshows look like ads and get skipped. A trending sound makes your content feel native.
4. **Creative control.** You can preview the final slideshow with music before it goes live. If something looks off, fix it before publishing.
This is the workflow that helped us hit 1M+ TikTok views and $670/month MRR. Don't skip the music step.
**Tell the user during onboarding:** "Posts will land in your TikTok inbox as drafts. Before publishing each one, add a trending sound from TikTok's library — this is the single biggest factor in reach. It takes 30 seconds and makes a massive difference."
Cross-posts to any connected platforms (Instagram, YouTube, etc.) automatically via Postiz.
**Caption rules:** Long storytelling captions (3x more views). Structure: Hook → Problem → Discovery → What it does → Result → max 5 hashtags. Conversational tone.
### 4. Connect Post Analytics (After User Publishes)
After the user publishes from their TikTok inbox, the post needs to be connected to its TikTok video ID before per-post analytics work.
**⚠️ CRITICAL: Wait at least 1-2 hours after publishing before connecting.** TikTok's API has an indexing delay — if you try to connect immediately, the new video won't be in the list yet, and you might connect to the wrong video. This mistake is hard to undo (Postiz doesn't easily allow overwriting a release ID once set).
Use `scripts/check-analytics.js` to automate the connection:
```bash
node scripts/check-analytics.js --config tiktok-marketing/config.json --days 3 --connect
```
The script:
1. Fetches all Postiz posts from the last N days
2. Skips posts published less than 2 hours ago (indexing delay)
3. For unconnected posts, calls `GET /posts/{id}/missing` to get all TikTok videos on the account
4. Matches posts to videos chronologically (TikTok IDs are sequential: higher number = newer video)
5. Excludes already-connected video IDs to avoid duplicates
6. Connects each post via `PUT /posts/{id}/release-id`
7. Pulls per-post analytics (views, likes, comments, shares)
**How the matching works:**
- TikTok video IDs are sequential integers (e.g. `7605531854921354518`, `7605630185727118614`)
- Higher number = more recently published
- Sort both Postiz posts (by publish date) and TikTok IDs (numerically) in the same order
- Match them up: oldest post → lowest unconnected ID, newest post → highest unconnected ID
- This is reliable because both Postiz and TikTok maintain chronological order
**Manual connection (if needed):**
1. `GET /posts/{id}/missing` — returns all TikTok videos with thumbnail URLs
2. Identify the correct video by thumbnail or timing
3. `PUT /posts/{id}/release-id` with `{"releaseId": "tiktok-video-id"}`
4. `GET /analytics/post/{id}` now returns views/likes/comments/shares
**The daily cron handles all of this automatically.** It runs in the morning, checks posts from the last 3 days (all well past the 2-hour indexing window), connects any unconnected posts, and generates the report.
### ⚠️ Known Issue: Release ID Cannot Be Overwritten
Once a Postiz post is connected to a TikTok video ID via `PUT /posts/{id}/release-id`, **it cannot be changed**. If you connect the wrong video, the analytics will permanently show the wrong video's stats for that post. The PUT endpoint appears to accept the update but silently keeps the original ID.
**This is why the 2-hour wait is non-negotiable.** If you connect too early (before TikTok has indexed the new video), the `missing` endpoint will show older videos and you'll connect the wrong one. There is no undo.
**Best practice:**
1. Post as draft → user publishes with music
2. Wait at least 2 hours (the daily morning cron handles this naturally)
3. The newest unconnected TikTok video ID (highest number) corresponds to the most recently published video
4. Always verify: the number of unconnected Postiz posts should match the number of new TikTok video IDs since the last connection run
5. If something looks wrong, ask the user to confirm by checking the video thumbnail
See [references/analytics-loop.md](references/analytics-loop.md) for full Postiz analytics API docs.
---
## The Feedback Loop (CRITICAL — This is What Makes It Work)
This is what separates "posting TikToks" from "running a marketing machine." The daily cron pulls data from two sources:
1. **Postiz** → per-post TikTok analytics (views, likes, comments, shares)
2. **RevenueCat** (if connected) → conversion data (trial starts, paid subscriptions, revenue)
Combined, the agent can make intelligent decisions about what to do next — not guessing, not vibes, actual data-driven optimization.
### The Daily Cron (Set Up During Onboarding)
Every morning before the first post, the cron runs `scripts/daily-report.js`:
1. Pulls the last 3 days of posts from Postiz (posts peak at 24-48h)
2. Fetches per-post analytics for each (views, likes, comments, shares)
3. If RevenueCat is connected, pulls conversion events in the same window (24-72h attribution)
4. Cross-references: which posts drove views AND which drove paying users
5. Applies the diagnostic framework (below) to determine what's working
6. Generates `tiktok-marketing/reports/YYYY-MM-DD.md` with findings
7. Messages the user with a summary + suggested hooks for today
### The Diagnostic Framework
This is the core intelligence. Two axes: **views** (are people seeing it?) and **conversions** (are people paying?).
**High views + High conversions** → 🟢 SCALE IT
- This is working. Make 3 variations of the winning hook immediately
- Test different posting times to find the sweet spot
- Cross-post to more platforms for extra reach
- Don't change anything about the CTA — it's converting
**High views + Low conversions** → 🟡 FIX THE CTA
- The hook is doing its job — people are watching. But they're not downloading/subscribing
- Try different CTAs on slide 6 (direct vs subtle, "download" vs "search on App Store")
- Check if the app landing page matches the promise in the slideshow
- Test different caption structures — maybe the CTA is buried
- The hook is gold — don't touch it. Fix everything downstream
**Low views + High conversions** → 🟡 FIX THE HOOKS
- The people who DO see it are converting — the content and CTA are great
- But not enough people are seeing it, so the hook/thumbnail isn't stopping the scroll
- Test radically different hooks (person+conflict, POV, listicle, mistakes format)
- Try different posting times and different slide 1 images
- Keep the CTA and content structure identical — just change the hook
**Low views + Low conversions** → 🔴 FULL RESET
- Neither the hook nor the conversion path is working
- Try a completely different format or approach
- Research what's trending in the niche RIGHT NOW (use browser)
- Consider a different target audience angle
- Test new hook categories from scratch
- Reference competitor research for what's working for others
**High views + High downloads + Low paying subscribers** → 🔴 APP ISSUE
- The marketing is working. People are watching AND downloading. But they're not paying.
- This is NOT a content problem — the app onboarding, paywall, or pricing needs fixing.
- Check: Is the paywall shown at the right time? Is the free experience too generous?
- Check: Does the onboarding guide users to the "aha moment" before the paywall?
- Check: Is the pricing right? Too expensive for the perceived value?
- **This is a signal to pause posting and fix the app experience first**
**High views + Low downloads** → 🟡 CTA ISSUE
- People are watching but not downloading. The hooks work, the CTAs don't.
- Rotate through different CTAs: "link in bio", "search on App Store", app name only, "free to try"
- Check the App Store page — does it match what the TikTok shows?
- Check that "link in bio" actually works and goes to the right place
**The daily report automates all of this.** It cross-references TikTok views (Postiz) with downloads and revenue (RevenueCat) and tells you exactly which part of the funnel is broken — per post. It also auto-generates new hook suggestions based on your winning patterns and flags when CTAs need rotating.
### Hook Evolution
Track in `tiktok-marketing/hook-performance.json`:
```json
{
"hooks": [
{
"postId": "postiz-id",
"text": "My boyfriend said our flat looks like a catalogue",
"app": "snugly",
"date": "2026-02-15",
"views": 45000,
"likes": 1200,
"comments": 45,
"shares": 89,
"conversions": 4,
"cta": "Download Snugly — link in bio",
"lastChecked": "2026-02-16"
}
],
"ctas": [
{
"text": "Download [App] — link in bio",
"timesUsed": 5,
"totalViews": 120000,
"totalConversions": 8,
"conversionRate": 0.067
},
{
"text": "Search [App] on the App Store",
"timesUsed": 3,
"totalViews": 85000,
"totalConversions": 12,
"conversionRate": 0.141
}
],
"rules": {
"doubleDown": ["person-conflict-ai"],
"testing": ["listicle", "pov-format"],
"dropped": ["self-complaint", "price-comparison"]
}
}
```
**The daily report updates this automatically.** Each post gets tagged with its hook text, CTA, view count, and attributed conversions. Over time, this builds a clear picture of which hook + CTA combinations actually drive revenue — not just views.
**CTA rotation:** When the report detects high views but low conversions, it automatically recommends rotating to a different CTA and tracks performance of each CTA separately. The agent should tag every post with the CTA used so the data accumulates.
```
**Decision rules:**
- 50K+ views → DOUBLE DOWN — make 3 variations immediately
- 10K-50K → Good — keep in rotation
- 1K-10K → Try 1 more variation
- <1K twice → DROP — try something radically different
### CTA Testing
When views are good but conversions are low, cycle through CTAs:
- "Download [App] — link in bio"
- "[App] is free to try — link in bio"
- "I used [App] for this — link in bio"
- "Search [App] on the App Store"
- No explicit CTA (just app name visible)
Track which CTAs convert best per hook category.
---
## Posting Schedule
Optimal times (adjust for audience timezone):
- **7:30 AM** — catch early scrollers
- **4:30 PM** — afternoon break
- **9:00 PM** — evening wind-down
3x/day minimum. Consistency beats sporadic viral hits. 100 posts beats 1 viral.
## Cross-Posting
Postiz supports cross-posting the same content to multiple platforms simultaneously. Recommend:
- **Instagram Reels** — especially strong for beauty/lifestyle/home
- **YouTube Shorts** — long-tail discovery
- **Threads** — lightweight engagement driver
Same slides, different algorithms, more surface area. Each platform's algo evaluates content independently.
## App Category Templates
See [references/app-categories.md](references/app-categories.md) for category-specific slide prompts and hook formulas.
## Common Mistakes
| Mistake | Fix |
|---------|-----|
| 1536x1024 (landscape) | Use 1024x1536 (portrait) |
| Font at 5% | Use 6.5% of width |
| Text at bottom | Position at 30% from top |
| Different rooms per slide | Lock architecture in EVERY prompt |
| Labels not reactions | "Wait this is nice??" not "Modern style" |
| Only tracking views | Track conversions — views without revenue = vanity |
| Same hooks forever | Iterate based on data, test new formats weekly |
| No cross-posting | Use Postiz to post everywhere simultaneously |
| Connecting release ID too soon | Wait 2+ hours — TikTok API indexing delay |
| Wrong video connected | Can't overwrite — always verify before connecting |
| `spawnSync ETIMEDOUT` | Exec timeout too short — image gen takes 3-9 min for 6 slides. Use a 10-minute timeout or generate slides one at a time |

6
_meta.json Normal file
View File

@@ -0,0 +1,6 @@
{
"ownerId": "kn781zbkdewedj9bk4frgp84th819bbh",
"slug": "larry",
"version": "1.0.0",
"publishedAt": 1771325231274
}

View File

@@ -0,0 +1,151 @@
# Analytics & Feedback Loop
## Performance Tracking
### Postiz Analytics API
**Platform analytics** (followers, views, likes, comments, shares over time):
```
GET https://api.postiz.com/public/v1/analytics/{integrationId}
Authorization: {apiKey}
```
Response:
```json
[
{ "label": "Followers", "percentageChange": 2.4, "data": [{ "total": "1250", "date": "2025-01-01" }] },
{ "label": "Views", "percentageChange": 4, "data": [{ "total": "5000", "date": "2025-01-01" }] },
{ "label": "Total Likes", "data": [{ "total": "6709", "date": "2026-02-15" }] },
{ "label": "Recent Likes", "data": [{ "total": "6354", "date": "2026-02-15" }] },
{ "label": "Recent Comments", "data": [{ "total": "148", "date": "2026-02-15" }] },
{ "label": "Recent Shares", "data": [{ "total": "119", "date": "2026-02-15" }] },
{ "label": "Videos", "data": [{ "total": "43", "date": "2026-02-15" }] }
]
```
**Per-post analytics** (likes, comments per post):
```
GET https://api.postiz.com/public/v1/analytics/post/{postId}
Authorization: {apiKey}
```
Response:
```json
[
{ "label": "Likes", "percentageChange": 16.7, "data": [{ "total": "150", "date": "2025-01-01" }, { "total": "175", "date": "2025-01-02" }] },
{ "label": "Comments", "percentageChange": 20, "data": [{ "total": "25", "date": "2025-01-01" }, { "total": "30", "date": "2025-01-02" }] }
]
```
Note: Per-post analytics availability depends on the platform. TikTok may return empty arrays for some posts — in this case, fall back to the **delta method**: track platform-level view totals before and after each post to estimate per-post views.
**List posts** (to get post IDs for analytics):
```
GET https://api.postiz.com/public/v1/posts?startDate={ISO}&endDate={ISO}
Authorization: {apiKey}
```
### RevenueCat Integration (Optional)
If the user has RevenueCat, track conversions from TikTok:
- Downloads → Trial starts → Paid conversions
- UTM parameters in App Store link
- Compare conversion spikes with post timing
## The Feedback Loop
### After Every Post (24h)
Record in `hook-performance.json`:
```json
{
"posts": [
{
"date": "2026-02-15",
"hook": "boyfriend said flat looks like catalogue",
"hookCategory": "person-conflict-ai",
"views": 15000,
"likes": 450,
"comments": 23,
"saves": 89,
"postId": "postiz-id",
"appCategory": "home"
}
]
}
```
### Weekly Review
1. Sort posts by views
2. Identify top 3 hooks → create variations
3. Identify bottom 3 hooks → drop or radically change
4. Check if any hook CATEGORY consistently wins
5. Update prompt templates with learnings
### Decision Rules
| Views | Action |
|-------|--------|
| 50K+ | DOUBLE DOWN — make 3 variations immediately |
| 10K-50K | Good — keep in rotation, test tweaks |
| 1K-10K | Okay — try 1 more variation before dropping |
| <1K (twice) | DROP — radically different approach needed |
### What to Vary When Iterating
- **Same hook, different person:** "landlord" → "mum" → "boyfriend"
- **Same structure, different room/feature:** bedroom → kitchen → bathroom
- **Same images, different text:** proven images can be reused with new hooks
- **Same hook, different time:** morning vs evening posting
## Conversion Tracking
### Funnel
```
Views → Profile Visits → Link Clicks → App Store → Download → Trial → Paid
```
### Benchmarks
- 1% conversion (views → download) = average
- 1.5-3% = good
- 3%+ = great
### Attribution Tips
- Track download spikes within 24h of viral post
- Use unique UTM links per campaign if possible
- RevenueCat `$attribution` for source tracking
- Compare weekly MRR growth with weekly view totals
## Daily Analytics Cron
Set up a cron job to run every morning before the first post (e.g. 7:00 AM user's timezone):
```
Task: node scripts/daily-report.js --config tiktok-marketing/config.json --days 3
Output: tiktok-marketing/reports/YYYY-MM-DD.md
```
The daily report:
1. Fetches all posts from the last 3 days via Postiz API
2. Pulls per-post analytics (views, likes, comments, shares)
3. If RevenueCat is connected, pulls conversion events (trials, purchases) in the same window
4. Cross-references: maps conversion timestamps to post publish times (24-72h attribution window)
5. Applies the diagnostic framework:
- High views + High conversions → SCALE (make variations)
- High views + Low conversions → FIX CTA (hook works, downstream is broken)
- Low views + High conversions → FIX HOOKS (content converts, needs more eyeballs)
- Low views + Low conversions → FULL RESET (try radically different approach)
6. Suggests 3-5 new hooks based on what's working
7. Updates `hook-performance.json` with latest data
8. Messages the user with a summary
### Why 3 Days?
- TikTok posts peak at 24-48 hours (not instant like Twitter)
- Conversion attribution takes up to 72 hours (user sees post → downloads → trials → pays)
- 3-day window captures the full lifecycle of each post
### RevenueCat Integration
When connected, the daily report pulls:
- **Trial starts** within 24-72h of each post → maps to which hooks drive installs
- **Paid conversions** (initial purchase + trial converted) → maps to which CTAs convert
- **Revenue** per period → tracks actual MRR impact of content
This is the difference between "this post got 50K views" (vanity) and "this post generated $47 in new subscriptions" (intelligence).

View File

@@ -0,0 +1,68 @@
# App Category Templates
## Home / Interior Design
**Slide concept:** Same room, same angle, different interior styles.
**Base prompt template (adapt wording for your image gen provider):**
```
Realistic photo of a [STYLE] [room type] in a small flat. [Room dimensions].
Shot from [position]. [Window details]. [Door details]. [Key furniture with exact
positions]. [Floor type]. [Ceiling details]. Natural lighting, phone camera quality.
Portrait orientation.
```
**"Before" rules:**
- Modern but tired, NOT ancient
- Include flat screen TV on wall
- Everyday items: mugs, remote, magazines
- Magnolia walls, basic curtains, no decor
**Style transforms:** Mid-century modern, Scandinavian, Industrial luxe, Coastal, Japandi, Maximalist bohemian
---
## Beauty / Cosmetics
**Slide concept:** Same face, same angle, progressive enhancement.
**Base prompt template:**
```
Close-up portrait photo of a young woman, [age], [ethnicity], [features],
[hair], [makeup level]. [Expression], looking directly at camera. Natural
indoor lighting from window to the left. Plain wall background. Phone selfie
quality, natural skin texture with visible pores. Portrait orientation.
```
**Preservation rules:**
- Face shape PIXEL-PERFECT identical
- Nose, eyes EXACTLY the same
- ALL skin texture preserved (pores, freckles)
- NO skin smoothing or beauty filters
- ONLY change the target feature (lips, lashes, etc.)
---
## Fitness / Body
**Slide concept:** Same person, same pose, progressive transformation.
**Base prompt:** Mirror selfie or gym photo, consistent lighting and background.
**Transform progression:** Current → 1 month → 3 months → 6 months (subtle, believable changes)
---
## Productivity / SaaS
**Slide concept:** Before/after workflow visualization OR results demonstration.
**Approach:** Can use screenshots with text overlays rather than AI images. Show the messy "before" (spreadsheets, chaos) vs clean "after" (organized, automated).
---
## Food / Recipe
**Slide concept:** Same dish, different presentations or same ingredients, different meals.
**Base prompt:** Overhead shot of food on table, consistent tableware and background.

View File

@@ -0,0 +1,101 @@
# Competitor Research Guide
## Why This Matters
Before creating content, you MUST understand the landscape. What hooks are competitors using? What's getting views? What gaps exist? This research directly drives your hook strategy and content differentiation.
## Research Process
### 1. Ask for Browser Permission
Always ask the user before browsing. Something like:
> "I want to research what your competitors are doing on TikTok — what's getting views, what hooks they use, what's working. Can I use the browser to look around?"
### 2. TikTok Research
Search TikTok for the app's niche. Look for:
- **Competitor accounts** posting similar content (aim for 3-5)
- **Top-performing videos** in the niche — what hooks do they use?
- **Slide formats** — before/after, listicle, POV, tutorial, reaction
- **View counts** — what's average vs exceptional in this niche?
- **Posting frequency** — how often do successful accounts post?
- **CTAs** — "link in bio", "search on App Store", app name in text, etc.
- **Trending sounds** — what music/sounds are popular in this niche?
- **Comment sentiment** — what do people ask about? What complaints?
### 3. App Store Research
Check the app's category on App Store / Google Play:
- Competitor apps in the same category
- Their screenshots, descriptions, ratings
- What features they highlight
- Their pricing model (free, freemium, subscription)
- Review sentiment — what do users love/hate about competitors?
### 4. Gap Analysis
The most valuable output is identifying what competitors AREN'T doing:
- **Content gaps:** Formats no one is using (listicles? tutorials? comparisons?)
- **Hook gaps:** Emotional angles no one has tried
- **Platform gaps:** Are competitors only on TikTok? Instagram opportunity?
- **Audience gaps:** Is there an underserved segment?
- **Quality gaps:** Are competitor images/videos low effort? Can we do better?
### 5. Save Findings
Store in `tiktok-marketing/competitor-research.json`:
```json
{
"researchDate": "2026-02-16",
"competitors": [
{
"name": "CompetitorApp",
"tiktokHandle": "@competitor",
"followers": 50000,
"topHooks": ["hook text 1", "hook text 2"],
"avgViews": 15000,
"bestVideo": {
"views": 500000,
"hook": "The hook that went viral",
"format": "before-after slideshow",
"url": "https://tiktok.com/..."
},
"format": "before-after slideshows",
"postingFrequency": "daily",
"cta": "link in bio",
"strengths": "Great visuals, consistent posting",
"weaknesses": "Same hooks every time, no storytelling"
}
],
"nicheInsights": {
"trendingSounds": ["sound name 1"],
"commonFormats": ["before-after", "POV"],
"averageViews": 15000,
"topPerformingViews": 500000,
"gapOpportunities": "Nobody is doing person+conflict hooks in this niche",
"avoidPatterns": "Price comparison posts get <1K views consistently"
}
}
```
### 6. Share Findings Conversationally
Don't dump the JSON. Talk about it:
> "So I looked at what's out there in [niche]. The main players are [A], [B], and [C]. [A] is doing well with [format] — their best post got [X] views. But I noticed nobody's really doing [gap]. That's where I think we can win. Here's my plan..."
## Ongoing Research
Don't just research once. During weekly reviews:
- Check if competitors have posted new viral content
- Look for new entrants in the niche
- Monitor trending sounds and formats
- Update `competitor-research.json` with new findings
Reference competitor data when suggesting hooks — "Competitor X got 200K views with a landlord hook, let's try our version."

View File

@@ -0,0 +1,123 @@
# RevenueCat Integration
## Setup
Add RevenueCat config to `config.json`:
```json
{
"revenuecat": {
"v1SecretKey": "sk_...",
"projectId": "your-project-id"
}
}
```
Get the **V1 Secret API Key** from RevenueCat Dashboard → Project Settings → API Keys. Use the **secret** key (sk_), NOT the public key.
## API Endpoints
### Get Overview Metrics
RevenueCat doesn't expose dashboard overview via API. Use the V1 subscriber endpoint to track individual conversions, or scrape the dashboard via browser automation.
**Alternative: Webhooks.** Set up RevenueCat webhooks to log events (trial_started, initial_purchase, renewal, cancellation) to a local JSON file that the skill can read.
### Get Subscriber Info (V1)
```
GET https://api.revenuecat.com/v1/subscribers/{app_user_id}
Authorization: Bearer {v1SecretKey}
```
Returns: active subscriptions, entitlements, purchase history, management URL.
### List Subscribers (V2 — if available)
```
GET https://api.revenuecat.com/v2/projects/{projectId}/customers
Authorization: Bearer {v2SecretKey}
```
## Daily Report Script
`scripts/daily-report.js` runs daily to:
1. **Pull TikTok analytics** from Postiz (last 3 days of posts)
2. **Pull conversion data** from RevenueCat webhook logs OR manual input
3. **Cross-reference** post timing with conversion spikes
4. **Generate report** identifying which hooks drove actual revenue
### Cross-Reference Logic
```
For each day in last 3 days:
1. Get all TikTok posts and their view counts
2. Get all new trials + paid conversions from RevenueCat
3. Correlate: conversion spikes within 24h of high-view posts
4. Score each hook: (conversions in 24h window) / (views / 1000) = conversion rate per 1K views
5. Rank hooks by conversion rate, not just views
```
### Why 3 Days?
- TikTok posts peak at 24-48h then tail off
- Conversion attribution window is ~24-72h
- Shorter = miss delayed conversions, longer = too noisy
## Webhook Setup (Recommended)
In RevenueCat Dashboard → Project Settings → Webhooks:
1. Set webhook URL to your server OR log to file
2. Events to track:
- `INITIAL_PURCHASE` — new paid subscriber
- `TRIAL_STARTED` — new trial
- `TRIAL_CONVERTED` — trial → paid
- `RENEWAL` — existing subscriber renewed
- `CANCELLATION` — subscriber cancelled
- `EXPIRATION` — subscription expired
Store events in `tiktok-marketing/rc-events.json`:
```json
[
{
"event": "INITIAL_PURCHASE",
"timestamp": "2026-02-15T14:00:00Z",
"product": "fullAccessMonthly",
"revenue": 4.99,
"currency": "USD"
}
]
```
If no webhook available, the user can manually update this file or the agent can prompt for daily numbers:
- "How many new trials today?"
- "How many paid conversions?"
- "Current MRR?"
## Report Output
The daily report generates `tiktok-marketing/reports/YYYY-MM-DD.md`:
```markdown
# Daily Marketing Report — Feb 15, 2026
## TikTok Performance (Last 3 Days)
| Date | Hook | Views | Likes | Saves |
|------|------|-------|-------|-------|
| Feb 15 | boyfriend + catalogue | 12,400 | 340 | 67 |
| Feb 14 | sister prison cell | 8,200 | 215 | 43 |
| Feb 13 | nan hook | 3,100 | 89 | 12 |
## Conversions (Last 3 Days)
- New trials: 14
- Trial → Paid: 6
- New direct purchases: 2
- Revenue: $47.92
## Attribution
- Feb 15 spike (8 trials) correlates with "boyfriend + catalogue" post (12.4K views)
- Estimated conversion rate: 0.65 per 1K views (GOOD)
## Recommendations
- DOUBLE DOWN on relationship conflict hooks (boyfriend/sister/nan)
- Drop listicle format (Feb 13 — low views, 0 correlating conversions)
- Test: "My [person] didn't believe AI could redesign our [room]"
```

View File

@@ -0,0 +1,111 @@
# Slide Structure & Hook Writing
## The 6-Slide Formula (EXACTLY 6 — TikTok minimum)
| Slide | Purpose | Text Style |
|-------|---------|------------|
| 1 | HOOK — stop the scroll | Relatable problem, full hook text |
| 2 | PROBLEM — amplify pain | Build tension |
| 3 | DISCOVERY — turning point | "So I tried this" / "Then I found..." |
| 4 | TRANSFORMATION 1 — first result | Reaction: "Wait... this actually looks good?" |
| 5 | TRANSFORMATION 2 — escalate | Reaction: "Okay I'm obsessed" |
| 6 | CTA — call to action | App name + "link in bio" |
**SAME subject, SAME angle, DIFFERENT styles across all 6 slides.**
## Proven Hook Formulas
### Tier 1: Person + Conflict → AI → Changed Mind (BEST)
- "I showed my mum what AI thinks our [room] should look like" (161K views)
- "My landlord said I can't change anything so I showed her this" (124K views)
- "My boyfriend said our flat looks like [insult] so I showed him"
- "My flatmate wouldn't believe this is the same room"
### Tier 2: Relatable Budget Pain
- "POV: You have good taste but no budget"
- "IKEA budget, designer taste"
- "I can't afford an interior designer so I tried AI"
### Tier 3: Curiosity / Self-Discovery
- "I've always wondered what I'd look like with..."
- "I had to see if it would even suit me"
- "Everyone's getting [thing] but would it suit MY face?"
### What DOESN'T Work
- Self-focused complaints without conflict: "My flat is ugly" (low views)
- Fear/insecurity hooks for beauty: "Am I ugly without..." (people scroll past)
- Price comparison without story: "$500 vs $5000" (needs character)
## Hook Adaptation by Category
### Home/Interior Apps
Replace [room] and [style] with user's app focus:
- "My [person] said our [room] looks like [insult]"
- "I showed my [person] what AI thinks our [room] should look like"
- "[Person] wouldn't let me redecorate until I showed them this"
### Beauty Apps
- "My [person] got [treatment] and now I can't stop thinking about it"
- "I've always had [feature] and never known what [change] would look like"
- "Everyone keeps asking if I got [treatment] done"
### Fitness Apps
- "My trainer said I'd never look like [goal]"
- "I showed my gym buddy what AI thinks I could look like in 6 months"
### Productivity Apps
- "My boss said my workflow is a mess so I showed her this"
- "I was spending 4 hours on [task] until I found this"
## Image Prompt Template
Write ONE base description, reuse across all 6 slides:
```
[For AI image gen providers:]
iPhone photo of a [CONTEXT]. [DETAILED DESCRIPTION OF SUBJECT].
Shot from [CAMERA POSITION]. [SPECIFIC ARCHITECTURAL/PHYSICAL DETAILS].
Natural phone camera quality, realistic lighting. Portrait orientation.
[SLIDE-SPECIFIC STYLE CHANGES ONLY]
```
Adapt prompt style to your image gen provider — the key principles (same subject, same angle, different styles) apply regardless of which tool generates the images.
### What to Lock (same across all 6):
- Subject dimensions/features
- Camera angle/position
- Lighting direction
- Background elements
- Physical structure (windows, doors, body proportions)
### What Changes Per Slide (ONLY):
- Style/aesthetic
- Colors/textures
- Decor/accessories
- Expression (for faces)
## Caption Template
```
[hook matching slide 1] 😭 [2-3 sentences of relatable struggle].
So I found this app called [APP NAME] that [what it does in one sentence] -
you just [simple action] and it [result]. I tried [style 1] and [style 2]
and honestly?? [emotional reaction]. [funny/relatable closer]
#[niche1] #[niche2] #[niche3] #[niche4] #fyp
```
Keep it conversational. Tell a mini-story. Mention the app naturally, not salesy.
## Music (CRITICAL — Do NOT Skip)
Posts are published as **drafts (SELF_ONLY)** to TikTok inbox. Before publishing:
1. Open the draft in TikTok
2. Tap "Add sound" and browse trending sounds in your niche
3. Pick something popular — trending audio gets algorithmic boost
4. Preview the slideshow with the sound, then publish
**Why drafts?** TikTok's algorithm massively favours posts with trending sounds. Silent slideshows look like ads and get buried. Adding the right music is the difference between 1K and 100K views. An API can't pick what's trending right now — you need to browse the sound library.
This takes 30 seconds per post. Don't skip it.

192
scripts/add-text-overlay.js Normal file
View File

@@ -0,0 +1,192 @@
#!/usr/bin/env node
/**
* Add text overlays to slideshow images using node-canvas.
*
* Usage: node add-text-overlay.js --input <dir> --texts <texts.json>
*
* texts.json format:
* [
* "Slide 1 text with manual\nline breaks preferred",
* "Slide 2 text",
* ... 6 total
* ]
*
* TEXT RULES:
* - Use \n for manual line breaks (PREFERRED — gives you control)
* - If no \n provided, the script auto-wraps to fit within maxWidth
* - Keep lines to 4-6 words max for readability
* - Text is REACTIONS not labels ("Wait... this is nice??" not "Modern style")
* - No emoji (canvas can't render them)
*
* Reads slide1_raw.png through slide6_raw.png (or slide_1.png etc)
* Outputs slide1.png through slide6.png (or final_1.png etc)
*/
const { createCanvas, loadImage } = require('canvas');
const fs = require('fs');
const path = require('path');
const args = process.argv.slice(2);
function getArg(name) {
const idx = args.indexOf(`--${name}`);
return idx !== -1 ? args[idx + 1] : null;
}
const inputDir = getArg('input');
const textsPath = getArg('texts');
if (!inputDir || !textsPath) {
console.error('Usage: node add-text-overlay.js --input <dir> --texts <texts.json>');
process.exit(1);
}
const texts = JSON.parse(fs.readFileSync(textsPath, 'utf-8'));
if (texts.length !== 6) {
console.error('ERROR: texts.json must have exactly 6 entries');
process.exit(1);
}
/**
* Word-wrap text to fit within maxWidth.
* If the text already contains \n, splits on those first,
* then wraps any lines that are still too wide.
*/
function wrapText(ctx, text, maxWidth) {
// Strip emoji (canvas can't render them reliably)
const cleanText = text.replace(/[\u{1F300}-\u{1FAFF}\u{2600}-\u{27BF}]/gu, '').trim();
// Split on manual line breaks first
const manualLines = cleanText.split('\n');
const wrappedLines = [];
for (const line of manualLines) {
// Check if this line fits as-is
if (ctx.measureText(line.trim()).width <= maxWidth) {
wrappedLines.push(line.trim());
continue;
}
// Auto-wrap: split into words, build lines that fit
const words = line.trim().split(/\s+/);
let currentLine = '';
for (const word of words) {
const testLine = currentLine ? `${currentLine} ${word}` : word;
if (ctx.measureText(testLine).width <= maxWidth) {
currentLine = testLine;
} else {
if (currentLine) wrappedLines.push(currentLine);
currentLine = word;
}
}
if (currentLine) wrappedLines.push(currentLine);
}
return wrappedLines;
}
async function addTextOverlay(imgPath, text, outPath) {
const img = await loadImage(imgPath);
const canvas = createCanvas(img.width, img.height);
const ctx = canvas.getContext('2d');
ctx.drawImage(img, 0, 0);
// ─── Text settings (match our proven viral format) ───
const fontSize = Math.round(img.width * 0.065); // 6.5% of image width (~66px on 1024w)
const outlineWidth = Math.round(fontSize * 0.15); // 15% of font size for thick outline
const maxWidth = img.width * 0.75; // 75% of image width (padding for TikTok UI)
const lineHeight = fontSize * 1.25; // 125% line height for readability
ctx.font = `bold ${fontSize}px Arial`;
ctx.textAlign = 'center';
ctx.textBaseline = 'top';
// Wrap text to fit within maxWidth
const lines = wrapText(ctx, text, maxWidth);
// Calculate vertical position
// Center the text block at 30% from top
const totalTextHeight = lines.length * lineHeight;
const startY = (img.height * 0.30) - (totalTextHeight / 2) + (lineHeight / 2);
// Ensure text stays in safe zones (not top 10%, not bottom 20%)
const minY = img.height * 0.10;
const maxY = img.height * 0.80 - totalTextHeight;
const safeY = Math.max(minY, Math.min(startY, maxY));
const x = img.width / 2; // Center horizontally
for (let i = 0; i < lines.length; i++) {
const y = safeY + (i * lineHeight);
// Black outline (stroke first, then fill on top)
ctx.strokeStyle = '#000000';
ctx.lineWidth = outlineWidth;
ctx.lineJoin = 'round';
ctx.miterLimit = 2;
ctx.strokeText(lines[i], x, y);
// White fill
ctx.fillStyle = '#FFFFFF';
ctx.fillText(lines[i], x, y);
}
fs.writeFileSync(outPath, canvas.toBuffer('image/png'));
// Log the actual text layout for debugging
console.log(`${path.basename(outPath)}${lines.length} lines:`);
lines.forEach(l => console.log(` "${l}"`));
}
// Find input files (supports multiple naming conventions)
function findSlideFile(dir, num) {
const candidates = [
`slide${num}_raw.png`,
`slide_${num}.png`,
`slide${num}.png`,
`raw_${num}.png`,
`${num}.png`
];
for (const name of candidates) {
const p = path.join(dir, name);
if (fs.existsSync(p)) return p;
}
return null;
}
function outputName(dir, num, inputName) {
// If input is slide1_raw.png → output slide1.png
// If input is slide_1.png → output final_1.png
if (inputName.includes('_raw')) {
return path.join(dir, inputName.replace('_raw', ''));
}
if (inputName.startsWith('slide_')) {
return path.join(dir, `final_${num}.png`);
}
return path.join(dir, `slide${num}_final.png`);
}
(async () => {
console.log('📝 Adding text overlays...\n');
console.log('Settings:');
console.log(' Font size: 6.5% of image width');
console.log(' Position: centered at ~30% from top');
console.log(' Max width: 75% of image');
console.log(' Style: white fill, black outline\n');
let success = 0;
for (let i = 0; i < 6; i++) {
const num = i + 1;
const inputFile = findSlideFile(inputDir, num);
if (!inputFile) {
console.error(` ❌ Slide ${num}: no input file found in ${inputDir}`);
continue;
}
const outPath = outputName(inputDir, num, path.basename(inputFile));
await addTextOverlay(inputFile, texts[i], outPath);
success++;
}
console.log(`\n${success}/6 overlays complete!`);
if (success < 6) process.exit(1);
})();

227
scripts/check-analytics.js Normal file
View File

@@ -0,0 +1,227 @@
#!/usr/bin/env node
/**
* TikTok Analytics Checker
*
* Connects Postiz posts to their TikTok video IDs and pulls per-post analytics.
*
* How it works:
* 1. Fetches all Postiz posts in the date range
* 2. For posts with releaseId="missing", calls /posts/{id}/missing to get TikTok video list
* 3. Matches posts to videos chronologically (TikTok IDs are sequential: higher = newer)
* 4. Connects each post to its TikTok video via PUT /posts/{id}/release-id
* 5. Pulls per-post analytics (views, likes, comments, shares)
*
* IMPORTANT: TikTok's API takes 1-2 hours to index new videos. Don't run this
* on posts published less than 2 hours ago — the video won't be in the list yet.
* The daily cron runs in the morning, checking posts from the last 3 days, which
* avoids this timing issue entirely.
*
* Usage: node check-analytics.js --config <config.json> [--days 3] [--connect] [--app snugly]
*
* --connect: Actually connect release IDs (without this flag, it's dry-run)
* --app: Filter to a specific app/integration name
* --days: How many days back to check (default: 3)
*/
const fs = require('fs');
const path = require('path');
const args = process.argv.slice(2);
function getArg(name) {
const idx = args.indexOf(`--${name}`);
return idx !== -1 ? args[idx + 1] : null;
}
const configPath = getArg('config');
const days = parseInt(getArg('days') || '3');
const shouldConnect = args.includes('--connect');
const appFilter = getArg('app');
if (!configPath) {
console.error('Usage: node check-analytics.js --config <config.json> [--days 3] [--connect] [--app name]');
process.exit(1);
}
const config = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
const BASE_URL = 'https://api.postiz.com/public/v1';
const API_KEY = config.postiz.apiKey;
async function api(method, endpoint, body = null) {
const opts = {
method,
headers: { 'Authorization': API_KEY, 'Content-Type': 'application/json' }
};
if (body) opts.body = JSON.stringify(body);
const res = await fetch(`${BASE_URL}${endpoint}`, opts);
return res.json();
}
async function sleep(ms) { return new Promise(r => setTimeout(r, ms)); }
(async () => {
const now = new Date();
const startDate = new Date(now - days * 86400000);
// Don't check posts from the last 2 hours (TikTok indexing delay)
const cutoffDate = new Date(now - 2 * 3600000);
console.log(`📊 Checking analytics (last ${days} days, cutoff: posts before ${cutoffDate.toISOString().slice(11, 16)} UTC)\n`);
// 1. Get all posts in range
const postsData = await api('GET', `/posts?startDate=${startDate.toISOString()}&endDate=${now.toISOString()}`);
let posts = postsData.posts || [];
// Filter by app if specified
if (appFilter) {
posts = posts.filter(p => p.integration?.name?.toLowerCase().includes(appFilter.toLowerCase()));
}
// Filter to TikTok posts only
posts = posts.filter(p => p.integration?.providerIdentifier === 'tiktok');
// Sort by publish date (oldest first)
posts.sort((a, b) => new Date(a.publishDate) - new Date(b.publishDate));
console.log(`Found ${posts.length} TikTok posts\n`);
// 2. Separate connected vs unconnected
const connected = posts.filter(p => p.releaseId && p.releaseId !== 'missing');
const unconnected = posts.filter(p => !p.releaseId || p.releaseId === 'missing');
// Filter unconnected to only posts older than 2 hours
const connectableUnconnected = unconnected.filter(p => new Date(p.publishDate) < cutoffDate);
const tooNew = unconnected.filter(p => new Date(p.publishDate) >= cutoffDate);
console.log(` Connected: ${connected.length}`);
console.log(` Unconnected (ready): ${connectableUnconnected.length}`);
if (tooNew.length > 0) {
console.log(` Too new (< 2h, skipping): ${tooNew.length}`);
tooNew.forEach(p => console.log(` ⏳ "${(p.content || '').substring(0, 50)}..." — wait for TikTok to index`));
}
console.log('');
// 3. If there are connectable unconnected posts, get the TikTok video list
if (connectableUnconnected.length > 0 && shouldConnect) {
// Use the first unconnected post to get the missing list
const referencePost = connectableUnconnected[0];
console.log(`🔍 Fetching TikTok video list via post ${referencePost.id}...`);
const tiktokVideos = await api('GET', `/posts/${referencePost.id}/missing`);
if (Array.isArray(tiktokVideos) && tiktokVideos.length > 0) {
// TikTok IDs are sequential (higher = newer). Sort ascending.
const videoIds = tiktokVideos.map(v => v.id).sort();
// Get already-connected IDs to exclude them
const connectedIds = new Set(connected.map(p => p.releaseId));
const availableIds = videoIds.filter(id => !connectedIds.has(id));
console.log(` Found ${videoIds.length} TikTok videos, ${availableIds.length} unconnected\n`);
// Sort unconnected posts by publish date (oldest first)
// Sort available IDs ascending (oldest first)
// Match them up chronologically
const sortedAvailable = availableIds.sort();
// We need to match the N most recent available IDs to the N unconnected posts
// Take the last N available IDs (newest) to match with the unconnected posts
const idsToUse = sortedAvailable.slice(-connectableUnconnected.length);
for (let i = 0; i < connectableUnconnected.length; i++) {
const post = connectableUnconnected[i];
const videoId = idsToUse[i];
if (!videoId) {
console.log(` ⚠️ No matching video ID for "${(post.content || '').substring(0, 50)}..."`);
continue;
}
console.log(` 🔗 Connecting: "${(post.content || '').substring(0, 50)}..."`);
console.log(` Post: ${post.id} (${post.publishDate})`);
console.log(` TikTok: ${videoId}`);
const result = await api('PUT', `/posts/${post.id}/release-id`, { releaseId: videoId });
if (result.releaseId === videoId) {
console.log(` ✅ Connected`);
} else {
console.log(` ⚠️ Connection returned: ${JSON.stringify(result.releaseId)}`);
}
await sleep(1000);
}
console.log('');
} else {
console.log(` ⚠️ No TikTok videos found in missing list. Videos may need more time to index.\n`);
}
} else if (connectableUnconnected.length > 0 && !shouldConnect) {
console.log(` ${connectableUnconnected.length} posts need connecting. Run with --connect to auto-connect.\n`);
}
// 4. Pull analytics for all connected posts
console.log('📈 Per-Post Analytics:\n');
// Re-fetch posts to get updated release IDs
const updatedData = await api('GET', `/posts?startDate=${startDate.toISOString()}&endDate=${now.toISOString()}`);
let updatedPosts = (updatedData.posts || []).filter(p =>
p.integration?.providerIdentifier === 'tiktok' &&
p.releaseId && p.releaseId !== 'missing'
);
if (appFilter) {
updatedPosts = updatedPosts.filter(p => p.integration?.name?.toLowerCase().includes(appFilter.toLowerCase()));
}
updatedPosts.sort((a, b) => new Date(b.publishDate) - new Date(a.publishDate)); // newest first
const results = [];
for (const post of updatedPosts) {
const analytics = await api('GET', `/analytics/post/${post.id}`);
const metrics = {};
if (Array.isArray(analytics)) {
analytics.forEach(m => {
const latest = m.data?.[m.data.length - 1];
if (latest) metrics[m.label.toLowerCase()] = parseInt(latest.total) || 0;
});
}
const result = {
id: post.id,
date: post.publishDate?.slice(0, 10),
hook: (post.content || '').substring(0, 60),
app: post.integration?.name,
views: metrics.views || 0,
likes: metrics.likes || 0,
comments: metrics.comments || 0,
shares: metrics.shares || 0,
releaseId: post.releaseId
};
results.push(result);
const viewStr = result.views > 1000 ? `${(result.views / 1000).toFixed(1)}K` : result.views;
console.log(` ${result.date} | ${viewStr} views | ${result.likes} likes | ${result.comments} comments | ${result.shares} shares`);
console.log(` "${result.hook}..."`);
console.log(` ${result.app} | TikTok: ${result.releaseId}\n`);
await sleep(500);
}
// 5. Save results
const baseDir = path.dirname(configPath);
const analyticsPath = path.join(baseDir, 'analytics-snapshot.json');
const snapshot = {
date: now.toISOString(),
posts: results
};
fs.writeFileSync(analyticsPath, JSON.stringify(snapshot, null, 2));
console.log(`💾 Saved analytics snapshot to ${analyticsPath}`);
// 6. Summary
console.log('\n📊 Summary:');
const totalViews = results.reduce((s, r) => s + r.views, 0);
const totalLikes = results.reduce((s, r) => s + r.likes, 0);
console.log(` Total views: ${totalViews.toLocaleString()}`);
console.log(` Total likes: ${totalLikes.toLocaleString()}`);
console.log(` Posts tracked: ${results.length}`);
if (results.length > 0) {
const best = results.reduce((a, b) => a.views > b.views ? a : b);
const worst = results.reduce((a, b) => a.views < b.views ? a : b);
console.log(` Best: ${best.views.toLocaleString()} views — "${best.hook}..."`);
console.log(` Worst: ${worst.views.toLocaleString()} views — "${worst.hook}..."`);
}
})();

View File

@@ -0,0 +1,87 @@
#!/usr/bin/env node
/**
* Competitor Research — Save & Query Findings
*
* The actual research is done by the agent using the browser.
* This script manages the competitor-research.json file.
*
* Usage:
* node competitor-research.js --dir tiktok-marketing/ --summary
* node competitor-research.js --dir tiktok-marketing/ --add-competitor '{"name":"AppX","tiktokHandle":"@appx",...}'
* node competitor-research.js --dir tiktok-marketing/ --gaps
*/
const fs = require('fs');
const path = require('path');
const args = process.argv.slice(2);
const dir = args.includes('--dir') ? args[args.indexOf('--dir') + 1] : 'tiktok-marketing';
const filePath = path.join(dir, 'competitor-research.json');
function loadData() {
if (!fs.existsSync(filePath)) {
return {
researchDate: '',
competitors: [],
nicheInsights: { trendingSounds: [], commonFormats: [], gapOpportunities: '', avoidPatterns: '' }
};
}
return JSON.parse(fs.readFileSync(filePath, 'utf-8'));
}
function saveData(data) {
fs.writeFileSync(filePath, JSON.stringify(data, null, 2));
}
if (args.includes('--summary')) {
const data = loadData();
if (data.competitors.length === 0) {
console.log('No competitor research yet. Use the browser to research competitors first.');
process.exit(0);
}
console.log(`📊 Competitor Research (${data.researchDate})\n`);
console.log(`Found ${data.competitors.length} competitors:\n`);
data.competitors.forEach(c => {
console.log(` ${c.name} (${c.tiktokHandle || 'no handle'})`);
console.log(` Followers: ${c.followers || '?'} | Avg views: ${c.avgViews || '?'}`);
if (c.bestVideo) console.log(` Best: ${c.bestVideo.views} views — "${c.bestVideo.hook}"`);
if (c.strengths) console.log(` Strengths: ${c.strengths}`);
if (c.weaknesses) console.log(` Weaknesses: ${c.weaknesses}`);
console.log('');
});
if (data.nicheInsights?.gapOpportunities) {
console.log(`💡 Gap opportunities: ${data.nicheInsights.gapOpportunities}`);
}
if (data.nicheInsights?.avoidPatterns) {
console.log(`⚠️ Avoid: ${data.nicheInsights.avoidPatterns}`);
}
}
if (args.includes('--add-competitor')) {
const idx = args.indexOf('--add-competitor');
const json = args[idx + 1];
try {
const competitor = JSON.parse(json);
const data = loadData();
data.competitors.push(competitor);
data.researchDate = new Date().toISOString().split('T')[0];
saveData(data);
console.log(`✅ Added competitor: ${competitor.name}`);
} catch (e) {
console.error('Invalid JSON for competitor:', e.message);
process.exit(1);
}
}
if (args.includes('--gaps')) {
const data = loadData();
if (!data.nicheInsights) {
console.log('No niche insights yet.');
process.exit(0);
}
console.log('Gap Analysis:\n');
console.log(` Opportunities: ${data.nicheInsights.gapOpportunities || 'None recorded'}`);
console.log(` Avoid: ${data.nicheInsights.avoidPatterns || 'None recorded'}`);
console.log(` Common formats: ${(data.nicheInsights.commonFormats || []).join(', ') || 'None recorded'}`);
console.log(` Trending sounds: ${(data.nicheInsights.trendingSounds || []).join(', ') || 'None recorded'}`);
}

562
scripts/daily-report.js Normal file
View File

@@ -0,0 +1,562 @@
#!/usr/bin/env node
/**
* Daily Marketing Report
*
* Cross-references TikTok post analytics (via Postiz) with RevenueCat conversions
* to identify which hooks drive views AND revenue.
*
* Data sources:
* 1. Postiz API → per-post TikTok analytics (views, likes, comments, shares)
* 2. Postiz API → platform-level stats (followers, total views) for delta tracking
* 3. RevenueCat API (optional) → trials, conversions, revenue
*
* The diagnostic framework:
* - High views + High conversions → SCALE (make variations of winning hooks)
* - High views + Low conversions → FIX CTA (hook works, downstream is broken)
* - Low views + High conversions → FIX HOOKS (content converts, needs more eyeballs)
* - Low views + Low conversions → FULL RESET (try radically different approach)
*
* Usage: node daily-report.js --config <config.json> [--days 3]
* Output: tiktok-marketing/reports/YYYY-MM-DD.md
*/
const fs = require('fs');
const path = require('path');
const args = process.argv.slice(2);
function getArg(name) {
const idx = args.indexOf(`--${name}`);
return idx !== -1 ? args[idx + 1] : null;
}
const configPath = getArg('config');
const days = parseInt(getArg('days') || '3');
if (!configPath) {
console.error('Usage: node daily-report.js --config <config.json> [--days 3]');
process.exit(1);
}
const config = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
const baseDir = path.dirname(configPath);
const POSTIZ_URL = 'https://api.postiz.com/public/v1';
async function postizAPI(endpoint) {
const res = await fetch(`${POSTIZ_URL}${endpoint}`, {
headers: { 'Authorization': config.postiz.apiKey }
});
return res.json();
}
async function sleep(ms) { return new Promise(r => setTimeout(r, ms)); }
// RevenueCat API (if configured)
async function getRevenueCatMetrics(startDate, endDate) {
if (!config.revenuecat?.enabled || !config.revenuecat?.v2SecretKey) {
return null;
}
const RC_URL = 'https://api.revenuecat.com/v2';
const headers = {
'Authorization': `Bearer ${config.revenuecat.v2SecretKey}`,
'Content-Type': 'application/json'
};
try {
// Get overview metrics
const overviewRes = await fetch(`${RC_URL}/projects/${config.revenuecat.projectId}/metrics/overview`, {
headers
});
const overview = await overviewRes.json();
// Get recent transactions for conversion attribution
const txRes = await fetch(`${RC_URL}/projects/${config.revenuecat.projectId}/transactions?start_from=${startDate.toISOString()}&limit=100`, {
headers
});
const transactions = await txRes.json();
// Extract key metrics from overview array
const metricsMap = {};
if (overview.metrics) {
overview.metrics.forEach(m => { metricsMap[m.id] = m.value; });
}
return {
overview,
transactions: transactions.items || [],
mrr: metricsMap.mrr || 0,
activeTrials: metricsMap.active_trials || 0,
activeSubscribers: metricsMap.active_subscriptions || 0,
activeUsers: metricsMap.active_users || 0,
newCustomers: metricsMap.new_customers || 0,
revenue: metricsMap.revenue || 0
};
} catch (e) {
console.log(` ⚠️ RevenueCat API error: ${e.message}`);
return null;
}
}
// Load previous day's snapshot for delta tracking
function loadPreviousSnapshot() {
const snapshotPath = path.join(baseDir, 'analytics-snapshot.json');
if (fs.existsSync(snapshotPath)) {
return JSON.parse(fs.readFileSync(snapshotPath, 'utf-8'));
}
return null;
}
// Load previous platform stats for delta tracking
function loadPreviousPlatformStats() {
const statsPath = path.join(baseDir, 'platform-stats.json');
if (fs.existsSync(statsPath)) {
return JSON.parse(fs.readFileSync(statsPath, 'utf-8'));
}
return null;
}
function savePlatformStats(stats) {
const statsPath = path.join(baseDir, 'platform-stats.json');
fs.writeFileSync(statsPath, JSON.stringify(stats, null, 2));
}
(async () => {
const now = new Date();
const startDate = new Date(now - days * 86400000);
const dateStr = now.toISOString().slice(0, 10);
console.log(`📊 Daily Report — ${dateStr} (last ${days} days)\n`);
// ==========================================
// 1. POSTIZ: Per-post analytics
// ==========================================
const postsData = await postizAPI(`/posts?startDate=${startDate.toISOString()}&endDate=${now.toISOString()}`);
let posts = (postsData.posts || []).filter(p =>
p.integration?.providerIdentifier === 'tiktok' &&
p.releaseId && p.releaseId !== 'missing'
);
posts.sort((a, b) => new Date(b.publishDate) - new Date(a.publishDate));
console.log(` 📱 Found ${posts.length} connected TikTok posts\n`);
const postResults = [];
for (const post of posts) {
const analytics = await postizAPI(`/analytics/post/${post.id}`);
const metrics = {};
if (Array.isArray(analytics)) {
analytics.forEach(m => {
const latest = m.data?.[m.data.length - 1];
if (latest) metrics[m.label.toLowerCase()] = parseInt(latest.total) || 0;
});
}
postResults.push({
id: post.id,
date: post.publishDate?.slice(0, 10),
hook: (post.content || '').substring(0, 70),
app: post.integration?.name,
views: metrics.views || 0,
likes: metrics.likes || 0,
comments: metrics.comments || 0,
shares: metrics.shares || 0
});
await sleep(300);
}
// ==========================================
// 2. POSTIZ: Platform-level stats (delta tracking)
// ==========================================
const platformStats = {};
for (const [platform, intId] of Object.entries(config.postiz?.integrationIds || {})) {
const stats = await postizAPI(`/analytics/${intId}`);
if (Array.isArray(stats)) {
platformStats[platform] = {};
stats.forEach(m => {
const latest = m.data?.[m.data.length - 1];
platformStats[platform][m.label] = parseInt(latest?.total) || 0;
});
}
}
const prevPlatformStats = loadPreviousPlatformStats();
savePlatformStats({ date: dateStr, stats: platformStats });
// ==========================================
// 3. REVENUECAT: Conversion metrics (optional)
// ==========================================
let rcMetrics = null;
let rcPrevMetrics = null;
if (config.revenuecat?.enabled) {
console.log(` 💰 Fetching RevenueCat metrics...`);
rcMetrics = await getRevenueCatMetrics(startDate, now);
// Load previous RC snapshot for deltas
const rcSnapshotPath = path.join(baseDir, 'rc-snapshot.json');
if (fs.existsSync(rcSnapshotPath)) {
rcPrevMetrics = JSON.parse(fs.readFileSync(rcSnapshotPath, 'utf-8'));
}
if (rcMetrics) {
fs.writeFileSync(rcSnapshotPath, JSON.stringify({ date: dateStr, ...rcMetrics }, null, 2));
}
}
// ==========================================
// 4. GENERATE REPORT
// ==========================================
let report = `# Daily Marketing Report — ${dateStr}\n\n`;
// Per-app breakdown
const apps = [...new Set(postResults.map(p => p.app))];
for (const app of apps) {
const appPosts = postResults.filter(p => p.app === app);
appPosts.sort((a, b) => b.views - a.views);
report += `## ${app}\n\n`;
report += `| Date | Hook | Views | Likes | Comments | Shares |\n`;
report += `|------|------|------:|------:|---------:|-------:|\n`;
for (const p of appPosts) {
const viewStr = p.views > 1000 ? `${(p.views / 1000).toFixed(1)}K` : `${p.views}`;
report += `| ${p.date} | ${p.hook.substring(0, 45)}... | ${viewStr} | ${p.likes} | ${p.comments} | ${p.shares} |\n`;
}
const totalViews = appPosts.reduce((s, p) => s + p.views, 0);
const avgViews = appPosts.length > 0 ? Math.round(totalViews / appPosts.length) : 0;
report += `\n**Total views:** ${totalViews.toLocaleString()} | **Avg per post:** ${avgViews.toLocaleString()}\n\n`;
}
// Platform deltas
if (prevPlatformStats) {
report += `## Platform Growth (since last report)\n\n`;
for (const [platform, stats] of Object.entries(platformStats)) {
const prev = prevPlatformStats.stats?.[platform];
if (prev) {
const followerDelta = (stats.Followers || 0) - (prev.Followers || 0);
const viewDelta = (stats.Views || 0) - (prev.Views || 0);
report += `**${platform}:** +${followerDelta} followers, +${viewDelta.toLocaleString()} views\n`;
} else {
report += `**${platform}:** ${stats.Followers || 0} followers, ${(stats.Views || 0).toLocaleString()} total views\n`;
}
}
report += '\n';
}
// RevenueCat section
if (rcMetrics) {
report += `## Conversions (RevenueCat)\n\n`;
report += `- **MRR:** $${rcMetrics.mrr}\n`;
report += `- **Active subscribers:** ${rcMetrics.activeSubscribers}\n`;
report += `- **Active trials:** ${rcMetrics.activeTrials}\n`;
report += `- **Active users (28d):** ${rcMetrics.activeUsers}\n`;
report += `- **New customers (28d):** ${rcMetrics.newCustomers}\n`;
report += `- **Revenue (28d):** $${rcMetrics.revenue}\n`;
if (rcPrevMetrics) {
const mrrDelta = rcMetrics.mrr - (rcPrevMetrics.mrr || 0);
const subDelta = rcMetrics.activeSubscribers - (rcPrevMetrics.activeSubscribers || 0);
const trialDelta = rcMetrics.activeTrials - (rcPrevMetrics.activeTrials || 0);
const userDelta = rcMetrics.activeUsers - (rcPrevMetrics.activeUsers || 0);
const customerDelta = rcMetrics.newCustomers - (rcPrevMetrics.newCustomers || 0);
report += `\n**Changes since last report:**\n`;
report += `- MRR: ${mrrDelta >= 0 ? '+' : ''}$${mrrDelta}\n`;
report += `- Subscribers: ${subDelta >= 0 ? '+' : ''}${subDelta}\n`;
report += `- Trials: ${trialDelta >= 0 ? '+' : ''}${trialDelta}\n`;
report += `- Active users: ${userDelta >= 0 ? '+' : ''}${userDelta}\n`;
report += `- New customers: ${customerDelta >= 0 ? '+' : ''}${customerDelta}\n`;
// Funnel diagnostic
report += `\n**Funnel health:**\n`;
if (customerDelta > 10 && subDelta === 0) {
report += `- ⚠️ Users are downloading (${customerDelta > 0 ? '+' : ''}${customerDelta} new customers) but nobody is subscribing → **App issue** (onboarding/paywall/pricing)\n`;
} else if (customerDelta > 10 && subDelta > 0) {
report += `- ✅ Funnel working: +${customerDelta} customers → +${subDelta} subscribers (${((subDelta / customerDelta) * 100).toFixed(1)}% conversion)\n`;
} else if (customerDelta <= 5) {
report += `- ⚠️ Few new customers (${customerDelta > 0 ? '+' : ''}${customerDelta}) → **Marketing issue** (views not converting to downloads — check App Store page, link in bio)\n`;
}
if (userDelta > 20 && subDelta === 0) {
report += `- 🔴 ${userDelta} active users but zero new subs → Users are trying the app but not paying. Check: Is the paywall too aggressive? Is the free experience too good? Is the value proposition clear?\n`;
}
}
// Attribution: compare conversion spikes with post timing
if (rcMetrics.transactions?.length > 0) {
report += `\n### Conversion Attribution (last ${days} days)\n\n`;
report += `Found ${rcMetrics.transactions.length} transactions. Cross-referencing with post timing:\n\n`;
for (const p of postResults.slice(0, 10)) { // top 10 posts
const postDate = new Date(p.date);
const windowEnd = new Date(postDate.getTime() + 72 * 3600000);
const nearbyTx = rcMetrics.transactions.filter(tx => {
const txDate = new Date(tx.purchase_date || tx.created_at);
return txDate >= postDate && txDate <= windowEnd;
});
if (nearbyTx.length > 0) {
report += `- "${p.hook.substring(0, 40)}..." (${p.views.toLocaleString()} views) → **${nearbyTx.length} conversions within 72h**\n`;
}
}
}
report += '\n';
}
// ==========================================
// 5. DIAGNOSTIC FRAMEWORK
// ==========================================
report += `## Diagnosis\n\n`;
for (const app of apps) {
const appPosts = postResults.filter(p => p.app === app);
const avgViews = appPosts.length > 0
? appPosts.reduce((s, p) => s + p.views, 0) / appPosts.length : 0;
// Determine conversion quality (if RC available)
let conversionGood = false;
let hasConversionData = false;
let usersGrowing = false;
if (rcMetrics && rcPrevMetrics) {
hasConversionData = true;
const subDelta = rcMetrics.activeSubscribers - (rcPrevMetrics.activeSubscribers || 0);
const trialDelta = rcMetrics.activeTrials - (rcPrevMetrics.activeTrials || 0);
const userDelta = rcMetrics.activeUsers - (rcPrevMetrics.activeUsers || 0);
conversionGood = (subDelta + trialDelta) > 2;
usersGrowing = userDelta > 10;
}
const viewsGood = avgViews > 10000;
report += `### ${app}\n\n`;
if (viewsGood && (!hasConversionData || conversionGood)) {
report += `🟢 **Views good${hasConversionData ? ' + Conversions good' : ''}** → SCALE IT\n`;
report += `- Average ${Math.round(avgViews).toLocaleString()} views per post\n`;
report += `- Make 3 variations of the top-performing hooks\n`;
report += `- Test different posting times for optimization\n`;
report += `- Cross-post to Instagram Reels & YouTube Shorts\n`;
} else if (viewsGood && hasConversionData && !conversionGood) {
report += `🟡 **Views good + Conversions poor** → FIX THE CTA\n`;
report += `- People are watching (avg ${Math.round(avgViews).toLocaleString()} views) but not converting\n`;
report += `- Try different CTAs on slide 6 (direct vs subtle)\n`;
report += `- Check if app landing page matches the slideshow promise\n`;
report += `- Test different caption structures\n`;
report += `- DO NOT change the hooks — they're working\n`;
} else if (!viewsGood && hasConversionData && conversionGood) {
report += `🟡 **Views poor + Conversions good** → FIX THE HOOKS\n`;
report += `- People who see it convert, but not enough see it (avg ${Math.round(avgViews).toLocaleString()} views)\n`;
report += `- Test radically different hook categories\n`;
report += `- Try person+conflict, POV, listicle, mistakes formats\n`;
report += `- Test different posting times and slide 1 thumbnails\n`;
report += `- DO NOT change the CTA — it's converting\n`;
} else if (!viewsGood && (!hasConversionData || !conversionGood)) {
report += `🔴 **Views poor${hasConversionData ? ' + Conversions poor' : ''}** → NEEDS WORK\n`;
report += `- Average ${Math.round(avgViews).toLocaleString()} views per post\n`;
report += `- Try radically different format/approach\n`;
report += `- Research what's trending in the niche RIGHT NOW\n`;
report += `- Consider different target audience angle\n`;
report += `- Test new hook categories from scratch\n`;
if (!hasConversionData) {
report += `- ⚠️ No conversion data — consider connecting RevenueCat for full picture\n`;
}
}
report += '\n';
}
// ==========================================
// 6. HOOK + CTA PERFORMANCE TRACKING
// ==========================================
const hookPath = path.join(baseDir, 'hook-performance.json');
let hookData = { hooks: [], ctas: [], rules: { doubleDown: [], testing: [], dropped: [] } };
if (fs.existsSync(hookPath)) {
hookData = JSON.parse(fs.readFileSync(hookPath, 'utf-8'));
if (!hookData.ctas) hookData.ctas = [];
}
// Update hook performance with conversion data
for (const p of postResults) {
// Calculate conversions attributed to this post (72h window)
let conversions = 0;
if (rcMetrics?.transactions?.length > 0) {
const postDate = new Date(p.date);
const windowEnd = new Date(postDate.getTime() + 72 * 3600000);
conversions = rcMetrics.transactions.filter(tx => {
const txDate = new Date(tx.purchase_date || tx.created_at);
return txDate >= postDate && txDate <= windowEnd;
}).length;
}
const existing = hookData.hooks.find(h => h.postId === p.id);
if (existing) {
existing.views = p.views;
existing.likes = p.likes;
existing.conversions = conversions;
existing.lastChecked = dateStr;
} else {
hookData.hooks.push({
postId: p.id,
text: p.hook,
app: p.app,
date: p.date,
views: p.views,
likes: p.likes,
comments: p.comments,
shares: p.shares,
conversions,
cta: '', // agent should tag this when creating posts
lastChecked: dateStr
});
}
}
fs.writeFileSync(hookPath, JSON.stringify(hookData, null, 2));
// ==========================================
// 7. AUTOMATED FUNNEL DIAGNOSIS PER POST
// ==========================================
report += `## Per-Post Funnel Diagnosis\n\n`;
const hasRC = rcMetrics && rcPrevMetrics;
const allHooks = hookData.hooks.filter(h => h.lastChecked === dateStr);
if (allHooks.length > 0 && hasRC) {
// Sort by views descending
const sorted = [...allHooks].sort((a, b) => b.views - a.views);
const viewMedian = sorted[Math.floor(sorted.length / 2)]?.views || 1000;
for (const h of sorted) {
const highViews = h.views > viewMedian && h.views > 5000;
const hasConversions = h.conversions > 0;
report += `**"${h.text.substring(0, 55)}..."** — ${h.views.toLocaleString()} views, ${h.conversions} conversions\n`;
if (highViews && hasConversions) {
report += ` 🟢 Hook + CTA both working → SCALE this hook, keep the CTA\n`;
} else if (highViews && !hasConversions) {
report += ` 🟡 High views but no conversions → Hook is good, CTA needs changing. Try a different slide 6 CTA.\n`;
} else if (!highViews && hasConversions) {
report += ` 🟡 Low views but people who saw it converted → CTA is great, hook needs work. Try a stronger hook with the same CTA.\n`;
} else {
report += ` 🔴 Low views + no conversions → Drop this hook and CTA combination\n`;
}
report += '\n';
}
// Check for systemic app issues
const totalRecentViews = sorted.reduce((s, h) => s + h.views, 0);
const totalConversions = sorted.reduce((s, h) => s + h.conversions, 0);
const subDelta = rcMetrics.activeSubscribers - (rcPrevMetrics.activeSubscribers || 0);
const customerDelta = rcMetrics.newCustomers - (rcPrevMetrics.newCustomers || 0);
if (totalRecentViews > 50000 && customerDelta > 10 && subDelta <= 0) {
report += `### 🔴 APP ISSUE DETECTED\n\n`;
report += `Views are high (${totalRecentViews.toLocaleString()}) and people are downloading (+${customerDelta} new customers), but nobody is paying (${subDelta >= 0 ? '+' : ''}${subDelta} subscribers).\n`;
report += `This is NOT a marketing problem — the content is working. The app onboarding, paywall, or pricing needs attention.\n`;
report += `- Is the paywall shown at the right time?\n`;
report += `- Is the free experience too generous?\n`;
report += `- Is the value proposition clear before the paywall?\n`;
report += `- Does the onboarding guide users to the "aha moment"?\n\n`;
} else if (totalRecentViews > 50000 && customerDelta <= 3) {
report += `### 🟡 CTA ISSUE DETECTED\n\n`;
report += `Views are high (${totalRecentViews.toLocaleString()}) but very few people are downloading (+${customerDelta} new customers).\n`;
report += `The hooks are working but the CTAs aren't driving action. Rotate to a different CTA style.\n\n`;
}
} else if (!hasRC) {
report += `⚠️ No RevenueCat data — can only diagnose hooks (views), not CTAs (conversions). Connect RevenueCat for full funnel intelligence.\n\n`;
}
// ==========================================
// 8. AUTO-GENERATED HOOKS & CTAs
// ==========================================
report += `## Auto-Generated Recommendations\n\n`;
// Analyse all historical hooks to find patterns
const allHistorical = hookData.hooks.filter(h => h.views > 0);
for (const app of apps) {
const appHooks = allHistorical.filter(h => h.app === app);
if (appHooks.length === 0) continue;
appHooks.sort((a, b) => b.views - a.views);
const winners = appHooks.filter(h => h.views >= 10000);
const losers = appHooks.filter(h => h.views < 1000);
report += `### ${app}\n\n`;
// Identify winning patterns from actual hook text
if (winners.length > 0) {
report += `**Winning hooks (10K+ views):**\n`;
for (const w of winners.slice(0, 5)) {
report += `- "${w.text.substring(0, 60)}..." — ${w.views.toLocaleString()} views, ${w.conversions || 0} conversions\n`;
}
report += '\n';
// Extract patterns from winners
const winnerTexts = winners.map(w => w.text.toLowerCase());
const patterns = {
personConflict: winnerTexts.filter(t => /\b(showed|told|said|asked)\b/.test(t) && /\b(my |mum|mom|dad|landlord|boyfriend|girlfriend|friend|sister|brother|boss|nan)\b/.test(t)).length,
pov: winnerTexts.filter(t => t.startsWith('pov')).length,
question: winnerTexts.filter(t => t.includes('?')).length,
emotion: winnerTexts.filter(t => /\b(obsessed|can't believe|shocked|never thought|didn't expect)\b/.test(t)).length
};
const topPattern = Object.entries(patterns).sort((a, b) => b[1] - a[1])[0];
report += `**Strongest pattern:** ${topPattern[0]} (${topPattern[1]}/${winners.length} winners use this)\n\n`;
// Generate new hooks based on winning patterns
report += `**🔥 Generated hooks (based on your winners):**\n`;
report += `\n`;
report += `The agent should now generate 3-5 NEW hooks that follow the "${topPattern[0]}" pattern, using:\n`;
report += `- The app's product description from config\n`;
report += `- The winning hook structures above as templates\n`;
report += `- Different people/scenarios to keep it fresh\n\n`;
// Specific generation instructions for the agent
report += `**AGENT INSTRUCTION:** Read the winning hooks above. Identify the structure (who + conflict + reveal). `;
report += `Generate 3 new hooks that follow the SAME structure but with different people and scenarios. `;
report += `Example: if "I showed my landlord..." won, try "I showed my nan...", "I showed my boss...", "My flatmate didn't believe..."\n\n`;
}
if (losers.length > 0) {
report += `**Drop these patterns (< 1K views):**\n`;
for (const l of losers.slice(0, 3)) {
report += `- "${l.text.substring(0, 60)}..." — ${l.views} views\n`;
}
report += '\n';
}
// CTA recommendations based on conversion data
if (hasRC) {
const highViewLowConvert = appHooks.filter(h => h.views > 10000 && (h.conversions || 0) === 0);
const lowViewHighConvert = appHooks.filter(h => h.views < 5000 && (h.conversions || 0) > 0);
if (highViewLowConvert.length > 0) {
report += `**🔄 CTA rotation needed** — ${highViewLowConvert.length} posts got 10K+ views but zero conversions.\n`;
report += `Current CTAs aren't driving downloads. Try rotating through:\n`;
report += `- "Download [app] — link in bio"\n`;
report += `- "[app] is free to try — link in bio"\n`;
report += `- "I used [app] for this — link in bio"\n`;
report += `- "Search [app] on the App Store"\n`;
report += `- No explicit CTA (just app name visible on slide 6)\n`;
report += `Track which CTA each post uses in hook-performance.json to identify what converts.\n\n`;
}
if (lowViewHighConvert.length > 0) {
report += `**💎 Hidden gems** — ${lowViewHighConvert.length} posts got low views but high conversions.\n`;
report += `The CTA on these posts is working. Reuse that CTA with stronger hooks.\n`;
for (const g of lowViewHighConvert) {
report += `- "${g.text.substring(0, 50)}..." — ${g.views} views, ${g.conversions} conversions (CTA: ${g.cta || 'unknown'})\n`;
}
report += '\n';
}
}
}
// ==========================================
// 8. SAVE REPORT
// ==========================================
const reportsDir = path.join(baseDir, 'reports');
fs.mkdirSync(reportsDir, { recursive: true });
const reportPath = path.join(reportsDir, `${dateStr}.md`);
fs.writeFileSync(reportPath, report);
console.log(`\n📋 Report saved to ${reportPath}`);
console.log('\n' + report);
})();

231
scripts/generate-slides.js Normal file
View File

@@ -0,0 +1,231 @@
#!/usr/bin/env node
/**
* Generate 6 TikTok slideshow images using the user's chosen image generation provider.
*
* Supported providers:
* - openai (gpt-image-1.5 STRONGLY RECOMMENDED — never use gpt-image-1)
* - stability (Stable Diffusion via Stability AI API)
* - replicate (any model via Replicate API)
* - local (user provides pre-made images, skips generation)
*
* Usage: node generate-slides.js --config <config.json> --output <dir> --prompts <prompts.json>
*
* prompts.json format:
* {
* "base": "Shared base prompt for all slides",
* "slides": ["Slide 1 additions", "Slide 2 additions", ...6 total]
* }
*/
const fs = require('fs');
const path = require('path');
const args = process.argv.slice(2);
function getArg(name) {
const idx = args.indexOf(`--${name}`);
return idx !== -1 ? args[idx + 1] : null;
}
const configPath = getArg('config');
const outputDir = getArg('output');
const promptsPath = getArg('prompts');
if (!configPath || !outputDir || !promptsPath) {
console.error('Usage: node generate-slides.js --config <config.json> --output <dir> --prompts <prompts.json>');
process.exit(1);
}
const config = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
const prompts = JSON.parse(fs.readFileSync(promptsPath, 'utf-8'));
if (!prompts.slides || prompts.slides.length !== 6) {
console.error('ERROR: prompts.json must have exactly 6 slides');
process.exit(1);
}
fs.mkdirSync(outputDir, { recursive: true });
const provider = config.imageGen?.provider || 'openai';
const model = config.imageGen?.model || 'gpt-image-1.5';
const apiKey = config.imageGen?.apiKey;
if (!apiKey && provider !== 'local') {
console.error(`ERROR: No API key found in config.imageGen.apiKey for provider "${provider}"`);
process.exit(1);
}
// Warn if using gpt-image-1 instead of 1.5
if (provider === 'openai' && model && !model.includes('1.5')) {
console.warn(`\n⚠️ WARNING: You're using "${model}" — this produces noticeably AI-looking images.`);
console.warn(` STRONGLY RECOMMENDED: Switch to "gpt-image-1.5" in your config for photorealistic results.`);
console.warn(` The quality difference is massive and directly impacts views.\n`);
}
// ─── Provider: OpenAI ───────────────────────────────────────────────
async function generateOpenAI(prompt, outPath) {
const res = await fetch('https://api.openai.com/v1/images/generations', {
method: 'POST',
headers: {
'Authorization': `Bearer ${apiKey}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({
model,
prompt,
n: 1,
size: '1024x1536',
quality: 'high'
}),
signal: global.__abortSignal
});
const data = await res.json();
if (data.error) throw new Error(data.error.message);
fs.writeFileSync(outPath, Buffer.from(data.data[0].b64_json, 'base64'));
}
// ─── Provider: Stability AI ─────────────────────────────────────────
async function generateStability(prompt, outPath) {
const engineId = model || 'stable-diffusion-xl-1024-v1-0';
const res = await fetch(`https://api.stability.ai/v1/generation/${engineId}/text-to-image`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${apiKey}`,
'Content-Type': 'application/json',
'Accept': 'application/json'
},
body: JSON.stringify({
text_prompts: [{ text: prompt, weight: 1 }],
cfg_scale: 7,
height: 1536,
width: 1024,
steps: 30,
samples: 1
})
});
const data = await res.json();
if (data.message) throw new Error(data.message);
fs.writeFileSync(outPath, Buffer.from(data.artifacts[0].base64, 'base64'));
}
// ─── Provider: Replicate ────────────────────────────────────────────
async function generateReplicate(prompt, outPath) {
const replicateModel = model || 'black-forest-labs/flux-1.1-pro';
// Create prediction
const createRes = await fetch('https://api.replicate.com/v1/predictions', {
method: 'POST',
headers: {
'Authorization': `Token ${apiKey}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({
model: replicateModel,
input: {
prompt,
width: 1024,
height: 1536,
num_outputs: 1
}
})
});
let prediction = await createRes.json();
if (prediction.error) throw new Error(prediction.error.detail || prediction.error);
// Poll for completion
while (prediction.status !== 'succeeded' && prediction.status !== 'failed') {
await new Promise(r => setTimeout(r, 2000));
const pollRes = await fetch(prediction.urls.get, {
headers: { 'Authorization': `Token ${apiKey}` }
});
prediction = await pollRes.json();
}
if (prediction.status === 'failed') throw new Error(prediction.error || 'Prediction failed');
// Download image
const imageUrl = Array.isArray(prediction.output) ? prediction.output[0] : prediction.output;
const imgRes = await fetch(imageUrl);
const buf = Buffer.from(await imgRes.arrayBuffer());
fs.writeFileSync(outPath, buf);
}
// ─── Provider: Local (skip generation) ──────────────────────────────
async function generateLocal(prompt, outPath) {
const slideNum = path.basename(outPath).match(/\d+/)?.[0];
const localPath = path.join(outputDir, `local_slide${slideNum}.png`);
if (fs.existsSync(localPath)) {
fs.copyFileSync(localPath, outPath);
} else {
throw new Error(`Place your image at ${localPath} — local provider skips generation`);
}
}
// ─── Retry with timeout ─────────────────────────────────────────────
async function withRetry(fn, retries = 2, timeoutMs = 120000) {
for (let attempt = 0; attempt <= retries; attempt++) {
try {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeoutMs);
// Pass abort signal via global (providers use fetch which supports it)
global.__abortSignal = controller.signal;
const result = await fn();
clearTimeout(timer);
return result;
} catch (e) {
if (attempt < retries) {
const isTimeout = e.name === 'AbortError' || e.message?.includes('timeout') || e.message?.includes('abort');
console.log(` ⚠️ ${isTimeout ? 'Timeout' : 'Error'}: ${e.message}. Retrying (${attempt + 1}/${retries})...`);
await new Promise(r => setTimeout(r, 3000 * (attempt + 1)));
} else {
throw e;
}
}
}
}
// ─── Router ─────────────────────────────────────────────────────────
const providers = {
openai: generateOpenAI,
stability: generateStability,
replicate: generateReplicate,
local: generateLocal
};
async function generate(prompt, outPath) {
const fn = providers[provider];
if (!fn) {
console.error(`Unknown provider: "${provider}". Supported: ${Object.keys(providers).join(', ')}`);
process.exit(1);
}
console.log(` Generating ${path.basename(outPath)} [${provider}/${model}]...`);
await withRetry(() => fn(prompt, outPath));
console.log(`${path.basename(outPath)}`);
}
(async () => {
console.log(`🎬 Generating 6 slides for ${config.app?.name || 'app'} using ${provider}/${model}\n`);
let success = 0;
let skipped = 0;
for (let i = 0; i < 6; i++) {
const outPath = path.join(outputDir, `slide${i + 1}_raw.png`);
// Skip if already exists (resume from partial run)
if (fs.existsSync(outPath) && fs.statSync(outPath).size > 10000) {
console.log(` ⏭ slide${i + 1}_raw.png already exists, skipping`);
success++;
skipped++;
continue;
}
const fullPrompt = `${prompts.base}\n\n${prompts.slides[i]}`;
try {
await generate(fullPrompt, outPath);
success++;
} catch (e) {
console.error(` ❌ Slide ${i + 1} failed after retries: ${e.message}`);
console.error(` Re-run this script to retry — completed slides will be skipped.`);
}
}
console.log(`\n✨ Generated ${success}/6 slides in ${outputDir}${skipped > 0 ? ` (${skipped} skipped — already existed)` : ''}`);
if (success < 6) {
console.error(`\n⚠️ ${6 - success} slides failed. Re-run to retry — completed slides are preserved.`);
process.exit(1);
}
})();

213
scripts/onboarding.js Normal file
View File

@@ -0,0 +1,213 @@
#!/usr/bin/env node
/**
* TikTok App Marketing — Onboarding Config Validator
*
* The onboarding is CONVERSATIONAL — the agent talks to the user naturally,
* not through this script. This script validates the resulting config is complete.
*
* Usage:
* node onboarding.js --validate --config tiktok-marketing/config.json
* node onboarding.js --init --dir tiktok-marketing/
*
* --validate: Check config completeness, show what's missing
* --init: Create the directory structure and empty config files
*/
const fs = require('fs');
const path = require('path');
const args = process.argv.slice(2);
const configPath = args.includes('--config') ? args[args.indexOf('--config') + 1] : null;
const validate = args.includes('--validate');
const init = args.includes('--init');
const dir = args.includes('--dir') ? args[args.indexOf('--dir') + 1] : 'tiktok-marketing';
if (init) {
// Create directory structure
const dirs = [dir, `${dir}/posts`, `${dir}/hooks`];
dirs.forEach(d => {
if (!fs.existsSync(d)) {
fs.mkdirSync(d, { recursive: true });
console.log(`📁 Created ${d}/`);
}
});
// Empty config template
const configTemplate = {
app: {
name: '',
description: '',
audience: '',
problem: '',
differentiator: '',
appStoreUrl: '',
category: '',
isMobileApp: false
},
imageGen: {
provider: '',
apiKey: '',
model: ''
},
postiz: {
apiKey: '',
integrationIds: {
tiktok: ''
}
},
revenuecat: {
enabled: false,
v2SecretKey: '',
projectId: ''
},
posting: {
privacyLevel: 'SELF_ONLY',
schedule: ['07:30', '16:30', '21:00'],
crossPost: []
},
competitors: `${dir}/competitor-research.json`,
strategy: `${dir}/strategy.json`
};
const cfgPath = `${dir}/config.json`;
if (!fs.existsSync(cfgPath)) {
fs.writeFileSync(cfgPath, JSON.stringify(configTemplate, null, 2));
console.log(`📝 Created ${cfgPath}`);
}
// Empty competitor research template
const compPath = `${dir}/competitor-research.json`;
if (!fs.existsSync(compPath)) {
fs.writeFileSync(compPath, JSON.stringify({
researchDate: '',
competitors: [],
nicheInsights: {
trendingSounds: [],
commonFormats: [],
gapOpportunities: '',
avoidPatterns: ''
}
}, null, 2));
console.log(`📝 Created ${compPath}`);
}
// Empty strategy template
const stratPath = `${dir}/strategy.json`;
if (!fs.existsSync(stratPath)) {
fs.writeFileSync(stratPath, JSON.stringify({
hooks: [],
postingSchedule: ['07:30', '16:30', '21:00'],
hookCategories: { testing: [], proven: [], dropped: [] },
crossPostPlatforms: [],
notes: ''
}, null, 2));
console.log(`📝 Created ${stratPath}`);
}
// Empty hook performance tracker
const hookPath = `${dir}/hook-performance.json`;
if (!fs.existsSync(hookPath)) {
fs.writeFileSync(hookPath, JSON.stringify({
hooks: [],
rules: { doubleDown: [], testing: [], dropped: [] }
}, null, 2));
console.log(`📝 Created ${hookPath}`);
}
console.log('\n✅ Directory structure ready. Start the conversational onboarding to fill in config.');
process.exit(0);
}
if (validate && configPath) {
if (!fs.existsSync(configPath)) {
console.error(`❌ Config not found: ${configPath}`);
process.exit(1);
}
const config = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
const required = [];
const optional = [];
// App profile (required)
if (!config.app?.name) required.push('app.name — What is the app called?');
if (!config.app?.description) required.push('app.description — What does it do?');
if (!config.app?.audience) required.push('app.audience — Who is it for?');
if (!config.app?.problem) required.push('app.problem — What problem does it solve?');
if (!config.app?.category) required.push('app.category — What category?');
// Image generation (required)
if (!config.imageGen?.provider) required.push('imageGen.provider — Which image tool?');
if (config.imageGen?.provider && config.imageGen.provider !== 'local' && !config.imageGen?.apiKey) {
required.push('imageGen.apiKey — API key for image generation');
}
// Postiz (required)
if (!config.postiz?.apiKey) required.push('postiz.apiKey — Postiz API key');
if (!config.postiz?.integrationIds?.tiktok) required.push('postiz.integrationIds.tiktok — TikTok integration ID');
// Competitor research (important but not blocking)
const compPath = config.competitors;
if (compPath && fs.existsSync(compPath)) {
const comp = JSON.parse(fs.readFileSync(compPath, 'utf-8'));
if (!comp.competitors || comp.competitors.length === 0) {
optional.push('Competitor research — no competitors analyzed yet (run browser research)');
}
} else {
optional.push('Competitor research — file not created yet');
}
// Strategy
const stratPath = config.strategy;
if (stratPath && fs.existsSync(stratPath)) {
const strat = JSON.parse(fs.readFileSync(stratPath, 'utf-8'));
if (!strat.hooks || strat.hooks.length === 0) {
optional.push('Content strategy — no hooks planned yet');
}
} else {
optional.push('Content strategy — file not created yet');
}
// RevenueCat (optional)
if (config.app?.isMobileApp && !config.revenuecat?.enabled) {
optional.push('RevenueCat — mobile app detected but RC not connected (recommended for conversion tracking)');
}
// App Store link
if (!config.app?.appStoreUrl) optional.push('App Store / website URL — helpful for competitor research');
// Results
if (required.length === 0) {
console.log('✅ Core config complete! Ready to start posting.\n');
} else {
console.log('❌ Missing required config:\n');
required.forEach(r => console.log(`${r}`));
console.log('');
}
if (optional.length > 0) {
console.log('💡 Recommended (not blocking):\n');
optional.forEach(o => console.log(`${o}`));
console.log('');
}
// Summary
console.log('📋 Setup Summary:');
console.log(` App: ${config.app?.name || '(not set)'}`);
console.log(` Category: ${config.app?.category || '(not set)'}`);
console.log(` Image Gen: ${config.imageGen?.provider || '(not set)'}${config.imageGen?.model ? ` (${config.imageGen.model})` : ''}`);
console.log(` TikTok: ${config.postiz?.integrationIds?.tiktok ? 'Connected' : 'Not connected'}`);
const crossPost = Object.keys(config.postiz?.integrationIds || {}).filter(k => k !== 'tiktok' && config.postiz.integrationIds[k]);
if (crossPost.length > 0) console.log(` Cross-posting: ${crossPost.join(', ')}`);
if (config.revenuecat?.enabled) console.log(` RevenueCat: Connected`);
console.log(` Privacy: ${config.posting?.privacyLevel || 'SELF_ONLY'}`);
console.log(` Schedule: ${(config.posting?.schedule || []).join(', ')}`);
process.exit(required.length > 0 ? 1 : 0);
} else {
console.log('Usage:');
console.log(' node onboarding.js --init --dir tiktok-marketing/ Create directory structure');
console.log(' node onboarding.js --validate --config config.json Validate config completeness');
}

116
scripts/post-to-tiktok.js Normal file
View File

@@ -0,0 +1,116 @@
#!/usr/bin/env node
/**
* Post a 6-slide TikTok slideshow via Postiz API.
*
* Usage: node post-to-tiktok.js --config <config.json> --dir <slides-dir> --caption "caption text" --title "post title"
*
* Uploads slide1.png through slide6.png, then creates a TikTok slideshow post.
* Posts as SELF_ONLY (draft) by default — user adds music then publishes.
*/
const fs = require('fs');
const path = require('path');
const args = process.argv.slice(2);
function getArg(name) {
const idx = args.indexOf(`--${name}`);
return idx !== -1 ? args[idx + 1] : null;
}
const configPath = getArg('config');
const dir = getArg('dir');
const caption = getArg('caption');
const title = getArg('title') || '';
if (!configPath || !dir || !caption) {
console.error('Usage: node post-to-tiktok.js --config <config.json> --dir <dir> --caption "text" [--title "text"]');
process.exit(1);
}
const config = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
const BASE_URL = 'https://api.postiz.com/public/v1';
async function uploadImage(filePath) {
const form = new FormData();
const blob = new Blob([fs.readFileSync(filePath)], { type: 'image/png' });
form.append('file', blob, path.basename(filePath));
const res = await fetch(`${BASE_URL}/upload`, {
method: 'POST',
headers: { 'Authorization': config.postiz.apiKey },
body: form
});
return res.json();
}
(async () => {
console.log('📤 Uploading slides...');
const images = [];
for (let i = 1; i <= 6; i++) {
const filePath = path.join(dir, `slide${i}.png`);
if (!fs.existsSync(filePath)) {
console.error(` ❌ Missing: ${filePath}`);
process.exit(1);
}
console.log(` Uploading slide ${i}...`);
const resp = await uploadImage(filePath);
if (resp.error) {
console.error(` ❌ Upload error: ${JSON.stringify(resp.error)}`);
process.exit(1);
}
images.push({ id: resp.id, path: resp.path });
console.log(`${resp.id}`);
// Rate limit buffer
if (i < 6) await new Promise(r => setTimeout(r, 1500));
}
console.log('\n📱 Creating TikTok post...');
const privacy = config.posting?.privacyLevel || 'SELF_ONLY';
const postRes = await fetch(`${BASE_URL}/posts`, {
method: 'POST',
headers: {
'Authorization': config.postiz.apiKey,
'Content-Type': 'application/json'
},
body: JSON.stringify({
type: 'now',
date: new Date().toISOString(),
shortLink: false,
tags: [],
posts: [{
integration: { id: config.postiz.integrationId },
value: [{ content: caption, image: images }],
settings: {
__type: 'tiktok',
title: title,
privacy_level: privacy,
duet: false,
stitch: false,
comment: true,
autoAddMusic: 'no',
brand_content_toggle: false,
brand_organic_toggle: false,
video_made_with_ai: true,
content_posting_method: 'UPLOAD'
}
}]
})
});
const result = await postRes.json();
console.log('✅ Posted!', JSON.stringify(result));
// Save metadata
const metaPath = path.join(dir, 'meta.json');
const meta = {
postId: result[0]?.postId,
caption,
title,
privacy,
postedAt: new Date().toISOString(),
images: images.length
};
fs.writeFileSync(metaPath, JSON.stringify(meta, null, 2));
console.log(`📋 Metadata saved to ${metaPath}`);
})();