
Viraloop
- 1 installs
- Updated May 27, 2026
- upload-post/upload-post-skills
Analyze a website URL, generate a 6-slide carousel with Gemini images, and auto-publish it to TikTok and Instagram with an analytics feedback loop.
About
Researches a brand from its URL, generates coherent viral carousel slides via Gemini, publishes them to TikTok and Instagram through the Upload-Post API, then accumulates learnings from analytics. A developer uses it to run a daily carousel-posting growth loop for a product.
- Generates 3 hook variants (shock/curiosity/contradiction) and 6 image-coherent slides
- Auto-publishes with trending music and tracks per-post analytics into learnings.json
Viraloop by the numbers
- 1 all-time installs (skills.sh)
- Ranked #1,983 of 2,715 Automation & Workflows skills by installs in the Skillselion catalog
- Data as of Aug 5, 2026 (Skillselion catalog sync)
npx skills add https://github.com/upload-post/upload-post-skills --skill viraloopAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 1 |
|---|---|
| Last updated | May 27, 2026 |
| Repository | upload-post/upload-post-skills ↗ |
What it does
Analyze a website URL, generate a 6-slide carousel with Gemini images, and auto-publish it to TikTok and Instagram with an analytics feedback loop.
Files
Viraloop
Analyze any website and generate a 6-slide carousel for TikTok/Instagram with visual coherence. Posts directly to your feed (no drafts) with auto trending music. Both APIs (Gemini + upload-post.com) have free plans — no credit card needed to start.
Philosophy: Daily Iteration Loop
The key to growth is consistency + learning:
1. Post 1 carousel per day - Consistency beats virality 2. Track everything - Every post generates data 3. Learn from data - What hooks work? What times? What visuals? 4. Iterate and improve - Each carousel is better than the last
Day 1: Post → Analyze → Learn
Day 2: Post (improved) → Analyze → Learn
Day 3: Post (better) → Analyze → Learn
...
Day 30: You have 30 data points and a refined strategyThe skill maintains a learnings.json that accumulates insights across all posts:
- Best performing hooks
- Optimal posting times
- Visual styles that work
- CTAs that convert
This is not a one-shot tool. It's a growth engine.
Quick Start
# 1. Full business research
node {baseDir}/scripts/analyze-web.js https://your-website.com
# 2. Generate 3 hook variants (SHOCK, CURIOSITY, CONTRADICTION)
GEMINI_API_KEY="your-key" bash {baseDir}/scripts/generate-hooks.sh
# 3. Review hooks with vision, pick the best one
# 4. Generate slides using selected hook type
GEMINI_API_KEY="your-key" bash {baseDir}/scripts/generate-slides.sh [shock|curiosity|contradiction]
# 5. Review slides (agent verifies text and quality)
# Agent uses vision to check each slide
# 6. Publish to TikTok + Instagram
UPLOADPOST_TOKEN="your-token" bash {baseDir}/scripts/publish-carousel.sh
# 7. Check analytics (after 24-48h)
UPLOADPOST_TOKEN="your-token" bash {baseDir}/scripts/check-analytics.sh 7
# 8. Learn from data for next carousels
node {baseDir}/scripts/learn-from-analytics.jsHook Categories (Visual Novelty System)
The key to breaking the 1000 views ceiling is visual novelty in the first slide. We generate 3 hook types:
| Type | Goal | Visual Style |
|---|---|---|
| SHOCK | Stop the scroll with impossible/absurd | Surreal, chaotic, "wait what?" |
| CURIOSITY | Create information gap | Mysterious, secrets being revealed |
| CONTRADICTION | Show impossible combination | Relaxed + intense, "how is that possible?" |
Anti-Stock Filter
All hooks include anti-stock prompting to avoid:
- Generic stock photo aesthetics
- Shocked pretty girl trope
- Typical influencer poses
- Overused laptop-coffee setups
- Corporate handshake scenes
Hook Testing Workflow
1. Generate 3 variants with generate-hooks.sh 2. Review with vision, score each 1-5 on novelty 3. Kill anything that looks familiar 4. Use best hook for carousel 5. Track which hook TYPE performs best over time
Image Model
Uses gemini-3.1-flash-image-preview to generate slides.
- Local script:
{baseDir}/scripts/generate_image.py - Supports image-to-image for visual coherence between slides
Step 1: Full Research (analyze-web.js)
Performs COMPLETE business investigation:
1. Brand Analysis
- Brand name (from domain or title)
- Logo (URL or SVG)
- CSS colors (background, primary, links, headings)
- Typography (body font, heading font)
- Favicon
2. Content Analysis
- Headline and tagline
- All headings (page structure)
- Features/benefits (title + description)
- Pricing/plans
- Testimonials
- Stats/metrics
- CTAs
- Meta tags (title, description, og:image)
3. Internal Pages
- Navigates pricing, features, about, testimonials
- Extracts additional content from each page
4. Competitor Detection
- Searches for known competitor mentions in content
- List of 20+ known SaaS competitors (Buffer, Hootsuite, Later, etc.)
- Detects "vs", "alternative", "compare" sections
- No external searches required - all from the website itself
5. Storytelling
- Detects business type (SaaS, ecommerce, app)
- Detects niche (social-media-tools, developer-tools, etc.)
- Generates hooks based on customer pain points
- Defines pain points from features
- Creates transformation narrative (before → after)
6. Visual Context
- Brand colors in CSS format
- Color description for prompts
- Typography for slides
- Image themes based on niche
- Style guide for coherence
Output: /tmp/carousel/analysis.json
Step 2: Generate Slides (generate-slides.sh)
Generates 6 slides with visual coherence using image-to-image:
| Slide | Type | Content |
|---|---|---|
| 1 | HOOK | Question/problem that hooks. Establishes ALL visual style. |
| 2 | Problem | Agitate the pain. "You upload to TikTok... then Instagram..." |
| 3 | Agitation | Competition advancing. Urgency. |
| 4 | Solution | Present the product with its value proposition. |
| 5 | Feature | Main benefit. |
| 6 | CTA | "Link in bio 👆" + call to action. |
Visual Coherence
- Slide 1 generates the base style (colors, typography, mood)
- Slides 2-6 use image-to-image with slide 1 as reference
- Maintains same color palette, fonts, and aesthetic
Prompts per Slide
Each slide has:
- Text: The main message
- Scene: Detailed visual description for background
- Style: Based on brand analysis
Output:
/tmp/carousel/slide-{1-6}.jpg/tmp/carousel/caption.txt
Step 3: Review with Vision
After generating, the agent MUST review each slide:
- ✓ Text is legible and correct
- ✓ Words are not cut off
- ✓ Correct spelling
- ✓ Visual coherence between slides
- ✓ Acceptable image quality
If there are errors, regenerate the specific slide.
Image Format
- Resolution: 768x1376 (9:16 vertical ratio)
- Format: JPG (TikTok does NOT accept PNG)
- Text: Large, bold, with shadow for readability
- Background: Scene relevant to the business, not just solid colors
Step 4: Publish to TikTok + Instagram (publish-carousel.sh)
Publishes the carousel to TikTok and Instagram using Upload-Post API.
UPLOADPOST_TOKEN="your-token" bash {baseDir}/scripts/publish-carousel.shEndpoint: POST /api/upload_photos
- Docs: https://docs.upload-post.com/api/upload-photo
Parameters sent:
platform[]=tiktok+platform[]=instagramauto_add_music=true- Adds music on TikTok automaticallytiktok_title- Short title (max 90 chars) + hashtagstitle- Full caption for Instagramprivacy_level=PUBLIC_TO_EVERYONEmedia_type=IMAGE- Photo carousel on Instagramasync_upload=true- Process in backgroundphotos[]- The 6 slides JPG
⚠️ IMPORTANT Instagram: After publishing, user must go to Instagram and add viral music manually: 1. Open Instagram → Profile → The post 2. Edit → Add music 3. Search for trending/viral song
Output: Saves request_id in post-info.json for tracking.
Step 5: Analyze Performance (check-analytics.sh)
Gets TikTok and Instagram analytics to see what works.
UPLOADPOST_TOKEN="your-token" bash {baseDir}/scripts/check-analytics.sh 7Endpoints used:
1. Profile analytics:
GET /api/analytics/{user}?platforms=tiktok→ Followers, likes, comments, shares, impressions
2. Total impressions:
GET /api/uploadposts/total-impressions/{user}?platform=tiktok&breakdown=true→ Total views per day
3. Per-post analytics:
GET /api/uploadposts/post-analytics/{request_id}→ Views, likes, comments for the specific carousel (TikTok + Instagram)
Docs: https://docs.upload-post.com/api/get-analytics
Step 6: Learn and Improve (learn-from-analytics.js)
Analyzes data and saves learnings for future carousels.
node {baseDir}/scripts/learn-from-analytics.jsGenerates:
learnings.json- Accumulated knowledge base- Best hooks (those generating most views)
- Optimal posting times
- Recommendations for next carousel
Full Flow
# 1. Research business
node {baseDir}/scripts/analyze-web.js https://my-product.com
# 2. Generate slides
GEMINI_API_KEY="..." bash {baseDir}/scripts/generate-slides.sh
# 3. Review with vision (agent verifies text)
# Agent checks each slide image
# 4. Publish
UPLOADPOST_TOKEN="..." UPLOADPOST_USER="myuser" bash {baseDir}/scripts/publish-carousel.sh
# 5. (After a few hours/days) Analyze
UPLOADPOST_TOKEN="..." UPLOADPOST_USER="myuser" bash {baseDir}/scripts/check-analytics.sh 7
# 6. Learn for next carousels
node {baseDir}/scripts/learn-from-analytics.jsFiles
{baseDir}/
├── SKILL.md # This documentation
└── scripts/
├── analyze-web.js # Full business research
├── generate-slides.sh # Generate 6 slides with coherence
├── generate_image.py # Gemini 3.1 flash image
├── review-slides.js # Prepare slides for review
├── publish-carousel.sh # Publish via Upload-Post API
├── check-analytics.sh # Get analytics (TikTok + Instagram)
├── learn-from-analytics.js # Learn from data
└── search-competitors.js # Optional external searchEnvironment Variables
| Variable | Description |
|---|---|
GEMINI_API_KEY | Google API key for image generation |
UPLOADPOST_TOKEN | Upload-Post token for publishing and analytics |
UPLOADPOST_USER | Upload-Post username (required) |
Hook Examples by Niche
| Niche | Hook Example |
|---|---|
| Social Media Tools | "Still posting to social media ONE BY ONE? 😩" |
| SaaS General | "Still doing this MANUALLY?" |
| Ecommerce | "The product TikTok won't stop recommending" |
| App | "The app I wish I'd discovered sooner" |
| Developer Tools | "The API that's going to change your code" |
Why Viraloop?
| Viraloop | Other skills | |
|---|---|---|
| Publishing | Direct to feed | Drafts only |
| Music | Auto trending music | Manual |
| Platforms | TikTok + Instagram | Single platform |
| Research | Auto URL analysis | Manual description |
| Image coherence | Image-to-image reference | Independent slides |
| Image gen | Gemini (free tier) | Paid providers |
| Posting | upload-post.com (free, no CC) | Paid or self-hosted |
| Text overlay | AI-native (Gemini renders) | External scripts |
| Prompts | Structured templates | Free-form |
| Setup | 3 env vars | Complex multi-tool setup |
Notes
- Analysis extracts competitors from the website content itself (20+ known SaaS)
- Image-to-image maintains coherence but takes ~20s per slide
- Generated caption includes niche-relevant hashtags
- Always review slide text - sometimes it gets cut off
auto_add_musicon TikTok improves engagement- Check analytics after 24-48h for meaningful data
- Learnings accumulate and improve with each published carousel
- TikTok title max 90 characters (auto-truncated with hashtags)
{
"name": "viraloop",
"version": "1.0.0",
"description": "Analyze any website and generate viral TikTok/Instagram carousel slides automatically. Full pipeline from research to publishing with analytics feedback loop.",
"requirements": {
"runtime": "node",
"minNodeVersion": "18",
"systemDeps": {
"required": ["jq"],
"optional": ["uv"],
"note": "Playwright is required for website analysis. uv is used to run Python scripts for image generation."
},
"npmDeps": ["playwright"]
},
"configPaths": [
"/tmp/carousel/analysis.json",
"/tmp/carousel/learnings.json",
"/tmp/carousel/post-info.json"
],
"credentials": {
"required": [
{
"name": "GEMINI_API_KEY",
"type": "envVar",
"description": "Google API key for Gemini image generation (gemini-3.1-flash-image-preview)",
"obtain": "https://aistudio.google.com/app/apikey",
"usage": "Set as environment variable before running generate-slides.sh"
},
{
"name": "UPLOADPOST_TOKEN",
"type": "envVar",
"description": "Upload-Post API token for publishing carousels and fetching analytics",
"obtain": "https://upload-post.com → Dashboard → API Keys",
"usage": "Set as environment variable before running publish-carousel.sh or check-analytics.sh"
}
],
"optional": [
{
"name": "UPLOADPOST_USER",
"type": "envVar",
"description": "Upload-Post username for publishing and analytics",
"sensitive": false
}
]
},
"dataStorage": {
"localFiles": [
"/tmp/carousel/analysis.json - Website analysis results",
"/tmp/carousel/slide-{1-6}.jpg - Generated carousel images",
"/tmp/carousel/caption.txt - Generated caption",
"/tmp/carousel/post-info.json - Published post metadata with request_id",
"/tmp/carousel/learnings.json - Accumulated performance insights"
],
"externalServices": [
"Gemini API (generativelanguage.googleapis.com) - Image generation",
"Upload-Post API (api.upload-post.com) - Publishing and analytics"
]
},
"securityNotes": [
"All credentials are read from environment variables, not stored in files",
"No API keys are hardcoded in any scripts",
"Website analysis uses Playwright to scrape public web pages only",
"Generated images and analytics data are stored locally in /tmp/carousel/",
"UPLOADPOST_USER is not sensitive - it's just a username identifier"
],
"permissions": {
"network": [
"Any website URL provided for analysis (read-only scraping)",
"generativelanguage.googleapis.com (Gemini API)",
"api.upload-post.com (Upload-Post API)"
],
"filesystem": ["/tmp/carousel/"],
"exec": ["node", "playwright", "uv", "python", "jq", "curl"]
}
}
.DS_Store
node_modules/
/tmp/
# Runtime data (not secrets, but instance-specific)
posts/
hook-ledger.json
learnings.json
# Environment files
.env
.env.*
*.local
{
"language": "es",
"defaultUrl": "https://upload-post.com",
"uploadPost": {
"profile": "influencersde",
"platforms": ["tiktok", "instagram"]
},
"imageFormat": "vertical",
"notes": "Generar slides en español para Upload-Post"
}
Viraloop 🔄
Generate viral TikTok/Instagram carousel slides from any website automatically.
Philosophy: Daily Iteration
Post 1 carousel per day. Learn. Iterate. Grow.
Day 1: Post → Analyze → Learn
Day 7: 7 data points, patterns emerging
Day 30: Refined strategy, proven hooks, optimal timingThe skill accumulates insights across ALL your posts in learnings.json.
Features
- 🔍 Full Business Research - Analyzes brand, features, competitors from any URL
- 🎨 AI Image Generation - Creates 6 visually coherent slides with Gemini (free plan available, no credit card)
- 📱 Direct Publishing - Posts straight to your TikTok feed with auto trending music + Instagram simultaneously. No drafts, no manual steps
- 💰 100% Free to Start - Both APIs (Gemini and upload-post.com) have free plans, no credit card required
- 📊 Analytics Tracking - Monitors performance across all posts
- 🔄 Learning Loop - Accumulates insights: best hooks, times, days, styles
Quick Start
# 1. Analyze website
node scripts/analyze-web.js https://your-product.com
# 2. Generate slides
GEMINI_API_KEY="..." bash scripts/generate-slides.sh
# 3. Publish
UPLOADPOST_TOKEN="..." bash scripts/publish-carousel.sh
# 4. Check analytics
UPLOADPOST_TOKEN="..." bash scripts/check-analytics.sh 7Requirements
- Node.js 18+
- Playwright (
npm install playwright && npx playwright install chromium) - uv (Python package runner)
- jq
Environment Variables
| Variable | Description |
|---|---|
GEMINI_API_KEY | Google API key for Gemini image generation |
UPLOADPOST_TOKEN | Upload-Post API token |
UPLOADPOST_USER | Upload-Post username |
Slide Structure
1. Hook - Attention-grabbing question/problem 2. Problem - Agitate the pain point 3. Agitation - Show competition advancing 4. Solution - Reveal your product 5. Feature - Key benefit 6. CTA - Call to action (Link in bio)
Credentials & Security
Required Credentials
| Credential | Type | Where to Get |
|---|---|---|
GEMINI_API_KEY | Environment variable | Google AI Studio |
UPLOADPOST_TOKEN | Environment variable | upload-post.com → Dashboard → API Keys |
Optional
| Credential | Type | Description |
|---|---|---|
UPLOADPOST_USER | Environment variable | Upload-Post username (not sensitive) |
Security Notes
- ✅ All credentials are read from environment variables, not stored in files
- ✅ No API keys are hardcoded in any scripts
- ✅ Website analysis only reads public web pages (no authentication scraping)
- ✅ All generated data stays local in
/tmp/carousel/
Data Flow
1. Website URL → Analyzed by Playwright (local browser) → analysis.json 2. Analysis → Sent to Gemini API → Returns generated images 3. Images + Caption → Sent to Upload-Post API → Published to TikTok/Instagram 4. Analytics → Fetched from Upload-Post API → Stored locally in learnings.json
External Services Used
| Service | Purpose | Data Sent |
|---|---|---|
| Gemini API | Image generation | Text prompts describing slides |
| Upload-Post API | Publishing + analytics | Images, captions, profile username |
No sensitive user data is transmitted. Only the content you're publishing.
Why Viraloop?
Most carousel automation skills post to drafts and require manual publishing. Viraloop is different:
| Feature | Viraloop | Other skills |
|---|---|---|
| Publishing | Direct to feed — posts go live instantly | Drafts only — you publish manually |
| Music | Auto trending music via auto_add_music | Manual — you add it yourself |
| Platforms | TikTok + Instagram in one call | Single platform, manual cross-post |
| Research | Auto-analyzes any URL — brand, competitors, pricing, features | Manual — you describe your product |
| Image coherence | Image-to-image — slides 2-6 reference slide 1 | Each slide generated independently |
| Image gen | Gemini (free tier available) | Paid image providers required |
| Posting service | upload-post.com — free plan, no credit card | Paid or self-hosted alternatives |
| Text on images | AI-native — text rendered directly | External scripts for text overlay |
| Prompt quality | Structured templates — explicit fields for consistency | Free-form prompts |
| Setup | 3 env vars and you're posting | Complex multi-tool setup |
Documentation
See SKILL.md for full documentation.
License
MIT
#!/usr/bin/env node
/**
* Web Analyzer v4 - Full business research
* Analyzes entire website + navigates internal pages
*
* The agent should complement with web_search for competitors
*/
const { chromium } = require('playwright');
const fs = require('fs');
const CAROUSEL_DIR = '/tmp/carousel';
async function delay(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
async function analyzeWebsite(url) {
console.log('═══════════════════════════════════════════════════════════════');
console.log(`🔍 FULL RESEARCH: ${url}`);
console.log('═══════════════════════════════════════════════════════════════\n');
const browser = await chromium.launch({ headless: true });
const context = await browser.newContext({
viewport: { width: 1920, height: 1080 },
userAgent: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36'
});
const page = await context.newPage();
fs.mkdirSync(CAROUSEL_DIR, { recursive: true });
const data = {
url,
brand: {},
content: { features: [], testimonials: [], stats: [], pricing: [], ctas: [] },
pages: {},
storytelling: {},
visualContext: {},
searchQueries: [] // Para que el agente busque
};
try {
// ═══════════════════════════════════════════════════════════════
// 1. HOMEPAGE - Full analysis
// ═══════════════════════════════════════════════════════════════
console.log('📌 1. ANALYZING HOMEPAGE...');
await page.goto(url, { waitUntil: 'networkidle', timeout: 30000 });
await delay(2000);
// Screenshot
await page.screenshot({ path: `${CAROUSEL_DIR}/homepage.png`, fullPage: false });
// Full scroll to load all content
await page.evaluate(async () => {
for (let i = 0; i < 5; i++) {
window.scrollTo(0, document.body.scrollHeight * (i / 5));
await new Promise(r => setTimeout(r, 500));
}
window.scrollTo(0, 0);
});
await delay(1000);
// Extract ALL info from homepage
const homeData = await page.evaluate(() => {
// Brand
const h1 = document.querySelector('h1')?.textContent?.trim() || '';
const title = document.title || '';
const metaDesc = document.querySelector('meta[name="description"]')?.content || '';
const logo = document.querySelector('header img, [class*="logo"] img, img[alt*="logo"]')?.src;
// Colores
const colors = [];
const buttons = document.querySelectorAll('button, a[class*="btn"], [class*="cta"]');
buttons.forEach(btn => {
const bg = getComputedStyle(btn).backgroundColor;
if (bg && bg !== 'rgba(0, 0, 0, 0)' && !colors.includes(bg)) {
colors.push(bg);
}
});
// Typography
const bodyFont = getComputedStyle(document.body).fontFamily.split(',')[0].replace(/['"]/g, '');
const h1El = document.querySelector('h1');
const headingFont = h1El ? getComputedStyle(h1El).fontFamily.split(',')[0].replace(/['"]/g, '') : bodyFont;
// Tagline/subtitle
const heroSection = document.querySelector('[class*="hero"], main > section:first-child, header + section, header + div');
let tagline = '';
if (heroSection) {
const p = heroSection.querySelector('p');
const h2 = heroSection.querySelector('h2');
tagline = p?.textContent?.trim() || h2?.textContent?.trim() || '';
}
if (!tagline) tagline = metaDesc;
// Todos los headings
const headings = [];
document.querySelectorAll('h1, h2, h3').forEach(h => {
const text = h.textContent?.trim();
if (text && text.length > 3 && text.length < 150 && !headings.find(x => x.text === text)) {
headings.push({ level: h.tagName, text });
}
});
// Features - search in multiple formats
const features = [];
const featureSelectors = [
'[class*="feature"]', '[class*="benefit"]', '[class*="card"]',
'[class*="service"]', '[class*="advantage"]', '[class*="why"]',
'section ul li', '.grid > div', '[class*="item"]'
];
featureSelectors.forEach(selector => {
document.querySelectorAll(selector).forEach(el => {
const title = el.querySelector('h2, h3, h4, strong, b')?.textContent?.trim();
const desc = el.querySelector('p')?.textContent?.trim();
if (title && title.length > 3 && title.length < 100 && features.length < 12) {
if (!features.find(f => f.title === title)) {
features.push({ title, description: desc?.substring(0, 200) || '' });
}
}
});
});
// Testimonials
const testimonials = [];
const testimonialSelectors = [
'[class*="testimonial"]', '[class*="review"]', '[class*="quote"]',
'blockquote', '[class*="customer"]', '[class*="feedback"]'
];
testimonialSelectors.forEach(selector => {
document.querySelectorAll(selector).forEach(el => {
const text = el.querySelector('p, .text, .content')?.textContent?.trim() || el.textContent?.trim();
const author = el.querySelector('[class*="author"], [class*="name"], cite, strong')?.textContent?.trim();
if (text && text.length > 30 && text.length < 500 && testimonials.length < 5) {
if (!testimonials.find(t => t.text === text)) {
testimonials.push({ text: text.substring(0, 300), author: author || 'Usuario' });
}
}
});
});
// Stats/numbers
const stats = [];
const statSelectors = [
'[class*="stat"]', '[class*="number"]', '[class*="metric"]',
'[class*="counter"]', '[class*="data"]', '[class*="kpi"]'
];
statSelectors.forEach(selector => {
document.querySelectorAll(selector).forEach(el => {
const text = el.textContent?.trim();
// Buscar patrones como "30k+", "1M", "99%", "$50"
const match = text.match(/[\d,]+[kKmM%+]?|\$[\d,]+/);
if (match && stats.length < 6) {
const label = el.querySelector('span, p, small')?.textContent?.trim() || '';
if (!stats.find(s => s.value === match[0])) {
stats.push({ value: match[0], label: label.substring(0, 50) });
}
}
});
});
// CTAs
const ctas = [];
document.querySelectorAll('a[class*="btn"], a[class*="cta"], button').forEach(el => {
const text = el.textContent?.trim();
if (text && text.length > 2 && text.length < 50 && !ctas.includes(text)) {
ctas.push(text);
}
});
// Important internal links
const internalLinks = [];
const importantPages = ['pricing', 'features', 'about', 'testimonials', 'reviews', 'customers', 'case', 'demo', 'contact'];
document.querySelectorAll('a[href]').forEach(a => {
const href = a.href.toLowerCase();
const text = a.textContent?.trim();
importantPages.forEach(page => {
if (href.includes(page) && !internalLinks.find(l => l.url === a.href)) {
internalLinks.push({ url: a.href, text, type: page });
}
});
});
return {
title, headline: h1, tagline, metaDesc, logo,
colors, bodyFont, headingFont,
headings, features, testimonials, stats, ctas,
internalLinks: internalLinks.slice(0, 8)
};
});
// Extract brand name (prioritize domain over generic title)
let brandName = homeData.title.split('|')[0].split('-')[0].trim();
const domain = url.replace(/https?:\/\//, '').replace('www.', '').split('.')[0];
// If title name is generic, use the domain
if (brandName.toLowerCase().includes('api') || brandName.toLowerCase().includes('tool') || brandName.length > 30) {
brandName = domain.charAt(0).toUpperCase() + domain.slice(1);
}
data.brand = {
name: brandName,
headline: homeData.headline,
tagline: homeData.tagline,
logo: homeData.logo,
colors: homeData.colors,
typography: { body: homeData.bodyFont, heading: homeData.headingFont }
};
data.content = {
headline: homeData.headline,
tagline: homeData.tagline,
metaDescription: homeData.metaDesc,
headings: homeData.headings,
features: homeData.features,
testimonials: homeData.testimonials,
stats: homeData.stats,
ctas: homeData.ctas.slice(0, 5),
pricing: []
};
console.log(` ✓ Brand: ${data.brand.name}`);
console.log(` ✓ Headline: "${homeData.headline?.substring(0, 50)}..."`);
console.log(` ✓ Features: ${homeData.features.length}`);
console.log(` ✓ Testimonials: ${homeData.testimonials.length}`);
console.log(` ✓ Stats: ${homeData.stats.length}`);
console.log(` ✓ CTAs: ${homeData.ctas.length}`);
console.log(` ✓ Internal links: ${homeData.internalLinks.length}`);
// ═══════════════════════════════════════════════════════════════
// 2. INTERNAL PAGES
// ═══════════════════════════════════════════════════════════════
console.log('\n📌 2. NAVIGATING INTERNAL PAGES...');
for (const link of homeData.internalLinks.slice(0, 5)) {
try {
console.log(` → ${link.type}: ${link.url.substring(0, 60)}...`);
await page.goto(link.url, { waitUntil: 'domcontentloaded', timeout: 15000 });
await delay(1500);
const pageData = await page.evaluate((pageType) => {
const content = { type: pageType, items: [] };
if (pageType === 'pricing') {
// Extract pricing plans
document.querySelectorAll('[class*="plan"], [class*="tier"], [class*="price"]').forEach(el => {
const name = el.querySelector('h2, h3, h4, [class*="name"]')?.textContent?.trim();
const price = el.querySelector('[class*="amount"], [class*="price"], .price')?.textContent?.trim();
const features = [];
el.querySelectorAll('li, [class*="feature"]').forEach(f => {
const t = f.textContent?.trim();
if (t && t.length < 100) features.push(t);
});
if (name || price) {
content.items.push({ name, price, features: features.slice(0, 5) });
}
});
} else if (pageType === 'testimonials' || pageType === 'reviews' || pageType === 'customers') {
// Extract more testimonials
document.querySelectorAll('[class*="testimonial"], [class*="review"], blockquote, [class*="quote"]').forEach(el => {
const text = el.querySelector('p')?.textContent?.trim() || el.textContent?.trim();
const author = el.querySelector('[class*="author"], [class*="name"]')?.textContent?.trim();
if (text && text.length > 20 && text.length < 500) {
content.items.push({ text: text.substring(0, 300), author });
}
});
} else if (pageType === 'features') {
// Extract more features
document.querySelectorAll('[class*="feature"], [class*="card"], section > div').forEach(el => {
const title = el.querySelector('h2, h3, h4')?.textContent?.trim();
const desc = el.querySelector('p')?.textContent?.trim();
if (title && title.length > 3 && title.length < 100) {
content.items.push({ title, description: desc?.substring(0, 200) });
}
});
}
return content;
}, link.type);
data.pages[link.type] = pageData;
// Merge data
if (link.type === 'pricing' && pageData.items.length > 0) {
data.content.pricing = pageData.items;
console.log(` ✓ Planes: ${pageData.items.length}`);
}
if ((link.type === 'testimonials' || link.type === 'reviews') && pageData.items.length > 0) {
data.content.testimonials.push(...pageData.items);
console.log(` ✓ Testimonials: +${pageData.items.length}`);
}
if (link.type === 'features' && pageData.items.length > 0) {
pageData.items.forEach(f => {
if (!data.content.features.find(x => x.title === f.title)) {
data.content.features.push(f);
}
});
console.log(` ✓ Features: +${pageData.items.length}`);
}
} catch (e) {
console.log(` ⚠️ Error: ${e.message.substring(0, 50)}`);
}
}
// ═══════════════════════════════════════════════════════════════
// 3. DETECTAR COMPETIDORES EN EL CONTENIDO
// ═══════════════════════════════════════════════════════════════
console.log('\n📌 3. DETECTING COMPETITORS...');
// Buscar menciones de competidores conocidos en el contenido
const knownCompetitors = [
'buffer', 'hootsuite', 'later', 'sprout social', 'socialbee',
'publer', 'metricool', 'ayrshare', 'socialbu', 'postiz',
'sendible', 'loomly', 'planable', 'sprinklr', 'falcon.io',
'brandwatch', 'hubspot', 'zoho social', 'tailwind', 'planoly'
];
const pageText = await page.evaluate(() => document.body.innerText.toLowerCase());
const detectedCompetitors = knownCompetitors.filter(c => pageText.includes(c.toLowerCase()));
// Buscar secciones "vs", "alternative", "compare"
const comparisonData = await page.evaluate(() => {
const comparisons = [];
document.querySelectorAll('a[href*="vs"], a[href*="alternative"], a[href*="compare"], [class*="compare"]').forEach(el => {
const text = el.textContent?.trim();
if (text && text.length < 100) comparisons.push(text);
});
return comparisons;
});
data.competitors = detectedCompetitors;
data.comparisons = comparisonData;
console.log(` ✓ Competitors detected: ${detectedCompetitors.length > 0 ? detectedCompetitors.join(', ') : 'none in content'}`);
// Nombre del producto
let productName = data.brand.name || url.replace(/https?:\/\//, '').split('.')[0];
if (productName.toLowerCase().includes('social media')) {
productName = url.replace(/https?:\/\//, '').replace('www.', '').split('.')[0];
}
productName = productName.charAt(0).toUpperCase() + productName.slice(1);
// ═══════════════════════════════════════════════════════════════
// 4. STORYTELLING
// ═══════════════════════════════════════════════════════════════
console.log('\n📌 4. GENERATING STORYTELLING...');
// Detect niche
const allText = JSON.stringify(data.content).toLowerCase();
let businessType = 'saas';
let niche = 'business';
if (/social media|instagram|tiktok|post|publish|schedule|content creator/i.test(allText)) {
niche = 'social-media-tools';
} else if (/api|developer|sdk|integration|code|github/i.test(allText)) {
niche = 'developer-tools';
} else if (/ecommerce|shop|store|cart|checkout|product catalog/i.test(allText)) {
businessType = 'ecommerce';
niche = 'ecommerce';
} else if (/app|mobile|ios|android|download/i.test(allText)) {
businessType = 'app';
niche = 'mobile-app';
} else if (/design|interior|architect|room|furniture|decor/i.test(allText)) {
niche = 'design';
} else if (/marketing|ads|campaign|seo|growth|funnel/i.test(allText)) {
niche = 'marketing';
} else if (/finance|banking|invest|payment|fintech/i.test(allText)) {
niche = 'finance';
} else if (/education|learn|course|student|teaching/i.test(allText)) {
niche = 'education';
} else if (/health|fitness|wellness|medical|workout/i.test(allText)) {
niche = 'health';
}
// Generate hooks based on niche
const hooks = [];
if (niche === 'social-media-tools') {
hooks.push(
'Still posting to social media ONE BY ONE? 😩',
'The trick creators use to be on ALL platforms at once',
'You\'re wasting HOURS every week doing this...',
'Why your competition is growing faster than you'
);
} else if (niche === 'developer-tools') {
hooks.push(
'Your code still does THIS? 💀',
'The API devs can\'t stop recommending',
'Stop wasting time on integrations'
);
} else if (niche === 'ecommerce') {
hooks.push(
'The product TikTok won\'t stop recommending',
'Why your online store is losing sales',
'Still managing inventory MANUALLY?'
);
} else if (niche === 'design') {
hooks.push(
'I showed my landlord what AI thinks our room should look like',
'Good taste but no budget? Try this',
'Wait... this is actually the same room??'
);
} else if (niche === 'health') {
hooks.push(
'The app I wish I discovered sooner',
'My trainer said I\'d never look like this',
'Still tracking your workouts on paper?'
);
} else if (niche === 'education') {
hooks.push(
'I learned more in 30 days than in 4 years of college',
'Why traditional learning is broken',
'The tool that made studying actually fun'
);
} else {
hooks.push(
'Still doing this MANUALLY?',
'The tool that\'s about to change your business',
'Why 90% of people are doing it WRONG'
);
}
data.storytelling = {
businessType,
niche,
productName,
uvp: data.content.tagline || data.content.metaDescription,
hooks,
painPoints: data.content.features.slice(0, 3).map(f => f.title),
socialProof: data.content.stats.length > 0
? `${data.content.stats[0].value} ${data.content.stats[0].label}`
: data.content.testimonials.length > 0
? `"${data.content.testimonials[0].text.substring(0, 100)}..."`
: null
};
console.log(` ✓ Type: ${businessType} / ${niche}`);
console.log(` ✓ Hooks: ${hooks.length}`);
// ═══════════════════════════════════════════════════════════════
// 5. CONTEXTO VISUAL
// ═══════════════════════════════════════════════════════════════
console.log('\n📌 5. DEFINING VISUAL CONTEXT...');
const imageThemes = {
'social-media-tools': ['content creator at desk with multiple screens', 'social media icons floating', 'person frustrated with phones', 'successful influencer celebrating'],
'developer-tools': ['code on screen', 'developer workspace', 'API connections visualization', 'clean tech interface'],
'ecommerce': ['online shopping', 'product showcase', 'happy customer unboxing', 'mobile shopping'],
'mobile-app': ['smartphone with app', 'person using phone', 'app interface mockup', 'mobile lifestyle']
};
data.visualContext = {
brandColors: data.brand.colors,
colorDescription: data.brand.colors.length > 0
? data.brand.colors.slice(0, 2).join(', ')
: 'purple and blue gradient, modern tech',
typography: data.brand.typography,
mood: 'professional, modern, vibrant',
imageThemes: imageThemes[niche] || imageThemes['social-media-tools'],
styleGuide: `Use ${data.brand.typography.heading || 'bold sans-serif'} font. Modern, clean aesthetic.`
};
console.log(` ✓ Visual themes: ${data.visualContext.imageThemes.length}`);
// ═══════════════════════════════════════════════════════════════
// GUARDAR
// ═══════════════════════════════════════════════════════════════
fs.writeFileSync(`${CAROUSEL_DIR}/analysis.json`, JSON.stringify(data, null, 2));
console.log('\n═══════════════════════════════════════════════════════════════');
console.log('✅ ANALYSIS COMPLETED');
console.log('═══════════════════════════════════════════════════════════════');
console.log(`\n📁 Saved to: ${CAROUSEL_DIR}/analysis.json`);
console.log(`\n📊 SUMMARY:`);
console.log(` Brand: ${data.brand.name}`);
console.log(` Type: ${businessType} / ${niche}`);
console.log(` Features: ${data.content.features.length}`);
console.log(` Testimonials: ${data.content.testimonials.length}`);
console.log(` Stats: ${data.content.stats.length}`);
console.log(` Pricing: ${data.content.pricing.length} plans`);
console.log(` Hooks: ${data.storytelling.hooks.length}`);
if (data.searchQueries.length > 0) {
console.log(`\n⚠️ PENDING: The agent should run web_search for:`);
data.searchQueries.forEach(q => console.log(` → "${q}"`));
}
return data;
} finally {
await browser.close();
}
}
const url = process.argv[2];
if (!url) {
console.error('Usage: node analyze-web.js <URL>');
process.exit(1);
}
analyzeWebsite(url).catch(console.error);
#!/bin/bash
# Get analytics for published posts via Upload-Post API
# Docs: https://docs.upload-post.com/api/get-analytics
# Usage: ./check-analytics.sh [days]
UPLOADPOST_URL="https://api.upload-post.com"
UPLOADPOST_TOKEN="${UPLOADPOST_TOKEN:?Error: UPLOADPOST_TOKEN is not set}"
DEFAULT_USER="${UPLOADPOST_USER:?Error: UPLOADPOST_USER is not set}"
DAYS="${1:-7}"
CAROUSEL_DIR="/tmp/carousel"
echo "═══════════════════════════════════════════════════════════════"
echo "📊 TIKTOK ANALYTICS - Last $DAYS days"
echo "═══════════════════════════════════════════════════════════════"
echo ""
echo "👤 Profile: $DEFAULT_USER"
echo ""
# ═══════════════════════════════════════════════════════════════
# 1. TikTok profile analytics
# GET /api/analytics/{profile_username}?platforms=tiktok
# ═══════════════════════════════════════════════════════════════
echo "📈 TikTok profile analytics..."
PROFILE_ANALYTICS=$(curl -s -H "Authorization: Apikey $UPLOADPOST_TOKEN" \
"$UPLOADPOST_URL/api/analytics/$DEFAULT_USER?platforms=tiktok")
if echo "$PROFILE_ANALYTICS" | jq -e '.tiktok' >/dev/null 2>&1; then
echo ""
echo "🎵 TikTok:"
echo "$PROFILE_ANALYTICS" | jq -r '.tiktok |
" 👥 Followers: \(.followers // "N/A")",
" 👀 Impressions: \(.impressions // "N/A")",
" ❤️ Likes: \(.likes // "N/A")",
" 💬 Comments: \(.comments // "N/A")",
" 🔄 Shares: \(.shares // "N/A")"
' 2>/dev/null
else
echo " ⚠️ No TikTok data"
echo "$PROFILE_ANALYTICS" | jq . 2>/dev/null | head -10
fi
echo ""
# ═══════════════════════════════════════════════════════════════
# 2. Total impressions (last N days)
# GET /api/uploadposts/total-impressions/{profile_username}
# ═══════════════════════════════════════════════════════════════
echo "📊 Total impressions (last $DAYS days)..."
# Calculate dates
END_DATE=$(date +%Y-%m-%d)
START_DATE=$(date -d "$DAYS days ago" +%Y-%m-%d 2>/dev/null || date -v-${DAYS}d +%Y-%m-%d)
IMPRESSIONS=$(curl -s -H "Authorization: Apikey $UPLOADPOST_TOKEN" \
"$UPLOADPOST_URL/api/uploadposts/total-impressions/$DEFAULT_USER?platform=tiktok&start_date=$START_DATE&end_date=$END_DATE&breakdown=true")
if echo "$IMPRESSIONS" | jq -e '.success' >/dev/null 2>&1; then
TOTAL=$(echo "$IMPRESSIONS" | jq -r '.total_impressions // 0')
echo ""
echo " 📈 Total impressions: $TOTAL"
# Show per day if data available
echo "$IMPRESSIONS" | jq -r '
if .per_day then
.per_day | to_entries | sort_by(.key) | reverse | .[:5][] |
" \(.key): \(.value) views"
else empty end
' 2>/dev/null
else
echo " ⚠️ No impressions data"
fi
echo ""
# ═══════════════════════════════════════════════════════════════
# 3. Specific post analytics (if post-info.json exists)
# GET /api/uploadposts/post-analytics/{request_id}
# ═══════════════════════════════════════════════════════════════
POST_INFO_FILE="$CAROUSEL_DIR/post-info.json"
if [ -f "$POST_INFO_FILE" ]; then
REQUEST_ID=$(jq -r '.request_id // empty' "$POST_INFO_FILE")
if [ -n "$REQUEST_ID" ]; then
echo "📝 Analytics of last published carousel..."
echo " Request ID: $REQUEST_ID"
POST_ANALYTICS=$(curl -s -H "Authorization: Apikey $UPLOADPOST_TOKEN" \
"$UPLOADPOST_URL/api/uploadposts/post-analytics/$REQUEST_ID")
if echo "$POST_ANALYTICS" | jq -e '.success' >/dev/null 2>&1; then
echo ""
# TikTok
echo "$POST_ANALYTICS" | jq -r '
.platforms.tiktok |
if . and .success then
" 🎵 TikTok:",
" URL: \(.post_url // "pending")",
" 👀 Views: \(.post_metrics.views // 0)",
" ❤️ Likes: \(.post_metrics.likes // 0)",
" 💬 Comments: \(.post_metrics.comments // 0)"
else empty end
' 2>/dev/null
# Instagram
echo "$POST_ANALYTICS" | jq -r '
.platforms.instagram |
if . and .success then
" 📸 Instagram:",
" URL: \(.post_url // "pendiente")",
" 👀 Reach: \(.post_metrics.reach // 0)",
" ❤️ Likes: \(.post_metrics.likes // 0)",
" 💬 Comments: \(.post_metrics.comments // 0)"
else empty end
' 2>/dev/null
else
echo " ⏳ Analytics not yet available (wait a few hours)"
fi
fi
fi
echo ""
# ═══════════════════════════════════════════════════════════════
# 4. Save snapshot for learn-from-analytics.js
# ═══════════════════════════════════════════════════════════════
SNAPSHOT_FILE="$CAROUSEL_DIR/analytics-snapshot.json"
echo "{
\"timestamp\": \"$(date -Iseconds)\",
\"days\": $DAYS,
\"user\": \"$DEFAULT_USER\",
\"profile\": $PROFILE_ANALYTICS,
\"impressions\": $IMPRESSIONS
}" > "$SNAPSHOT_FILE" 2>/dev/null || echo "{}" > "$SNAPSHOT_FILE"
echo "💾 Snapshot saved to: $SNAPSHOT_FILE"
echo ""
echo "═══════════════════════════════════════════════════════════════"
#!/usr/bin/env python3
# /// script
# requires-python = ">=3.10"
# dependencies = [
# "google-genai>=1.0.0",
# "pillow>=10.0.0",
# ]
# ///
"""
Generate images using Google's Nano Banana Pro (Gemini 3 Pro Image) API.
Usage:
uv run generate_image.py --prompt "your image description" --filename "output.png" [--resolution 1K|2K|4K] [--api-key KEY]
"""
import argparse
import os
import sys
from pathlib import Path
def get_api_key(provided_key: str | None) -> str | None:
"""Get API key from argument first, then environment."""
if provided_key:
return provided_key
return os.environ.get("GEMINI_API_KEY")
def main():
parser = argparse.ArgumentParser(
description="Generate images using Nano Banana Pro (Gemini 3 Pro Image)"
)
parser.add_argument(
"--prompt", "-p",
required=True,
help="Image description/prompt"
)
parser.add_argument(
"--filename", "-f",
required=True,
help="Output filename (e.g., sunset-mountains.png)"
)
parser.add_argument(
"--input-image", "-i",
help="Optional input image path for editing/modification"
)
parser.add_argument(
"--resolution", "-r",
choices=["1K", "2K", "4K"],
default="1K",
help="Output resolution: 1K (default), 2K, or 4K"
)
parser.add_argument(
"--api-key", "-k",
help="Gemini API key (overrides GEMINI_API_KEY env var)"
)
args = parser.parse_args()
# Get API key
api_key = get_api_key(args.api_key)
if not api_key:
print("Error: No API key provided.", file=sys.stderr)
print("Please either:", file=sys.stderr)
print(" 1. Provide --api-key argument", file=sys.stderr)
print(" 2. Set GEMINI_API_KEY environment variable", file=sys.stderr)
sys.exit(1)
# Import here after checking API key to avoid slow import on error
from google import genai
from google.genai import types
from PIL import Image as PILImage
# Initialise client
client = genai.Client(api_key=api_key)
# Set up output path
output_path = Path(args.filename)
output_path.parent.mkdir(parents=True, exist_ok=True)
# Load input image if provided
input_image = None
output_resolution = args.resolution
if args.input_image:
try:
input_image = PILImage.open(args.input_image)
print(f"Loaded input image: {args.input_image}")
# Auto-detect resolution if not explicitly set by user
if args.resolution == "1K": # Default value
# Map input image size to resolution
width, height = input_image.size
max_dim = max(width, height)
if max_dim >= 3000:
output_resolution = "4K"
elif max_dim >= 1500:
output_resolution = "2K"
else:
output_resolution = "1K"
print(f"Auto-detected resolution: {output_resolution} (from input {width}x{height})")
except Exception as e:
print(f"Error loading input image: {e}", file=sys.stderr)
sys.exit(1)
# Build contents (image first if editing, prompt only if generating)
if input_image:
contents = [input_image, args.prompt]
print(f"Editing image with resolution {output_resolution}...")
else:
contents = args.prompt
print(f"Generating image with resolution {output_resolution}...")
try:
response = client.models.generate_content(
model="gemini-3.1-flash-image-preview",
contents=contents,
config=types.GenerateContentConfig(
response_modalities=["TEXT", "IMAGE"],
image_config=types.ImageConfig(
image_size=output_resolution
)
)
)
# Process response and convert to PNG
image_saved = False
for part in response.parts:
if part.text is not None:
print(f"Model response: {part.text}")
elif part.inline_data is not None:
# Convert inline data to PIL Image and save as PNG
from io import BytesIO
# inline_data.data is already bytes, not base64
image_data = part.inline_data.data
if isinstance(image_data, str):
# If it's a string, it might be base64
import base64
image_data = base64.b64decode(image_data)
image = PILImage.open(BytesIO(image_data))
# Determine output format from file extension
ext = output_path.suffix.lower()
if ext in ('.jpg', '.jpeg'):
save_format = 'JPEG'
save_kwargs = {'quality': 92}
else:
save_format = 'PNG'
save_kwargs = {}
# Ensure correct color mode for output format
if save_format == 'JPEG' and image.mode in ('RGBA', 'P'):
rgb_image = PILImage.new('RGB', image.size, (255, 255, 255))
if image.mode == 'RGBA':
rgb_image.paste(image, mask=image.split()[3])
else:
rgb_image.paste(image)
rgb_image.save(str(output_path), save_format, **save_kwargs)
elif image.mode == 'RGBA' and save_format == 'PNG':
image.save(str(output_path), save_format, **save_kwargs)
elif image.mode == 'RGB':
image.save(str(output_path), save_format, **save_kwargs)
else:
image.convert('RGB').save(str(output_path), save_format, **save_kwargs)
image_saved = True
if image_saved:
full_path = output_path.resolve()
print(f"\nImage saved: {full_path}")
# Clawdbot parses MEDIA tokens and will attach the file on supported providers.
print(f"MEDIA: {full_path}")
else:
print("Error: No image was generated in the response.", file=sys.stderr)
sys.exit(1)
except Exception as e:
print(f"Error generating image: {e}", file=sys.stderr)
sys.exit(1)
if __name__ == "__main__":
main()
#!/bin/bash
# Generate multiple hook variants for A/B testing
# NOW WITH LEARNING: reads learnings.json and adapts hooks based on what works
set -e
CAROUSEL_DIR="/tmp/carousel"
ANALYSIS_FILE="$CAROUSEL_DIR/analysis.json"
UV_BIN="${HOME}/.local/bin/uv"
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
SKILL_DIR="$(dirname "$SCRIPT_DIR")"
NANO_SCRIPT="$SCRIPT_DIR/generate_image.py"
HOOKS_DIR="$CAROUSEL_DIR/hooks"
LEARNINGS_FILE="$SKILL_DIR/learnings.json"
mkdir -p "$HOOKS_DIR"
if [ ! -f "$ANALYSIS_FILE" ]; then
echo "❌ Error: $ANALYSIS_FILE not found"
exit 1
fi
if [ -z "$GEMINI_API_KEY" ]; then
echo "❌ Error: GEMINI_API_KEY is not set"
exit 1
fi
# Read analysis data
PRODUCT_NAME=$(jq -r '.storytelling.productName // "product"' "$ANALYSIS_FILE")
NICHE=$(jq -r '.storytelling.niche // "business"' "$ANALYSIS_FILE")
COLOR_DESC=$(jq -r '.visualContext.colorDescription // "purple and blue gradient"' "$ANALYSIS_FILE")
FONT=$(jq -r '.visualContext.typography.headingFont // "bold sans-serif"' "$ANALYSIS_FILE")
LANGUAGE="${VIRALOOP_LANG:-es}"
# ═══════════════════════════════════════════════════════════════
# LOAD LEARNINGS (if available)
# ═══════════════════════════════════════════════════════════════
PROMPT_BOOST=""
PREFER_TYPE=""
USE_WORDS=""
USE_EMOJIS=""
AVOID_WORDS=""
if [ -f "$LEARNINGS_FILE" ]; then
echo "🧠 Loading learnings from previous posts..."
PROMPT_BOOST=$(jq -r '.recommendations.promptBoost // ""' "$LEARNINGS_FILE")
PREFER_TYPE=$(jq -r '.recommendations.preferHookType // ""' "$LEARNINGS_FILE")
USE_WORDS=$(jq -r '.recommendations.useWords | join(", ")' "$LEARNINGS_FILE" 2>/dev/null || echo "")
USE_EMOJIS=$(jq -r '.recommendations.useEmojis | join(" ")' "$LEARNINGS_FILE" 2>/dev/null || echo "")
AVOID_WORDS=$(jq -r '.recommendations.avoidWords | join(", ")' "$LEARNINGS_FILE" 2>/dev/null || echo "")
if [ -n "$PROMPT_BOOST" ]; then
echo " ✅ Found learnings:"
echo " Prefer type: $PREFER_TYPE"
[ -n "$USE_WORDS" ] && echo " Use words: $USE_WORDS"
[ -n "$USE_EMOJIS" ] && echo " Use emojis: $USE_EMOJIS"
[ -n "$AVOID_WORDS" ] && echo " Avoid words: $AVOID_WORDS"
echo ""
fi
else
echo "📝 No learnings yet - will learn after first analytics check"
echo ""
fi
# Anti-stock filter
ANTI_STOCK="ABSOLUTELY AVOID: generic stock photo aesthetics, typical influencer poses, shocked pretty girl trope, overused laptop-coffee setups, fake smiles, corporate handshakes, typical business meeting scenes. Create something UNEXPECTED and WEIRD that stops the scroll."
echo "═══════════════════════════════════════════════════════════════"
echo "🎣 GENERATING 3 HOOK VARIANTS: $PRODUCT_NAME"
echo " Language: $LANGUAGE | Niche: $NICHE"
echo "═══════════════════════════════════════════════════════════════"
echo ""
# ═══════════════════════════════════════════════════════════════
# BUILD LEARNING-ENHANCED PROMPT
# ═══════════════════════════════════════════════════════════════
LEARNING_CONTEXT=""
if [ -n "$PROMPT_BOOST" ]; then
LEARNING_CONTEXT="
IMPORTANT - DATA FROM PREVIOUS POSTS:
$PROMPT_BOOST
Apply these learnings to make the hook more effective. The data shows what actually works with this audience."
fi
# ═══════════════════════════════════════════════════════════════
# GENERATE HOOKS USING GEMINI (dynamic, not hardcoded)
# ═══════════════════════════════════════════════════════════════
generate_hook_text() {
local HOOK_TYPE=$1
local HOOK_STYLE=$2
# Use Gemini to generate contextual hook text
local PROMPT="Generate ONE short TikTok hook text (max 8 words) in ${LANGUAGE^^} for a $NICHE product called '$PRODUCT_NAME'.
Hook type: $HOOK_TYPE
Style: $HOOK_STYLE
Requirements:
- Must be in ${LANGUAGE^^} (Spanish if es, English if en)
- Max 8 words + 1-2 emojis
- Must create immediate curiosity or shock
- Must feel native to TikTok (not corporate)
${LEARNING_CONTEXT}
Examples of $HOOK_TYPE hooks:
- SHOCK: '3 HORAS al día publicando = contenido MUERTO 💀'
- CURIOSITY: '¿Por qué los TOP publican 10x más rápido? 🤔'
- CONTRADICTION: 'Menos esfuerzo = MÁS alcance 📈'
Return ONLY the hook text, nothing else."
# Call Gemini API
local RESPONSE=$(curl -s "https://generativelanguage.googleapis.com/v1beta/models/gemini-1.5-flash:generateContent?key=$GEMINI_API_KEY" \
-H 'Content-Type: application/json' \
-d "{
\"contents\": [{\"parts\":[{\"text\": $(echo "$PROMPT" | jq -Rs .)}]}],
\"generationConfig\": {\"temperature\": 0.9, \"maxOutputTokens\": 50}
}" 2>/dev/null)
echo "$RESPONSE" | jq -r '.candidates[0].content.parts[0].text // ""' | tr -d '\n' | head -c 100
}
generate_scene_prompt() {
local HOOK_TYPE=$1
local HOOK_TEXT=$2
case "$HOOK_TYPE" in
shock)
echo "Surreal scene showing overwhelming chaos related to: $HOOK_TEXT. Person drowning in screens/tasks, everything on fire but they're smiling nervously. Nightmarish but eye-catching. Multiple clones doing same task."
;;
curiosity)
echo "Mysterious scene related to: $HOOK_TEXT. Split view: exhausted person on one side, relaxed successful person on other with mysterious glow. Question marks, intriguing shadows, secret being revealed."
;;
contradiction)
echo "Contradiction scene for: $HOOK_TEXT. Person completely relaxed (beach/hammock) while intense work happens automatically in background. Ultimate lifestyle contradiction - chill person + massive productivity."
;;
esac
}
# ═══════════════════════════════════════════════════════════════
# GENERATE ALL 3 HOOK TYPES
# ═══════════════════════════════════════════════════════════════
for HOOK_TYPE in shock curiosity contradiction; do
case "$HOOK_TYPE" in
shock)
EMOJI="🔴"
STYLE="Create fear/urgency. Show what happens if they DON'T act. Use words like: error, muerto, pierdes, problema."
;;
curiosity)
EMOJI="🟡"
STYLE="Create information gap. Ask a question they NEED answered. Use: ¿por qué?, ¿cómo?, secreto, truco."
;;
contradiction)
EMOJI="🟢"
STYLE="Show impossible contrast. Something that shouldn't work together. Use: menos=más, sin esfuerzo, automático."
;;
esac
echo "$EMOJI Generating $HOOK_TYPE hook..."
# Generate hook text with AI
HOOK_TEXT=$(generate_hook_text "$HOOK_TYPE" "$STYLE")
if [ -z "$HOOK_TEXT" ]; then
# Fallback if API fails
case "$HOOK_TYPE" in
shock) HOOK_TEXT="Tu contenido está MURIENDO 💀" ;;
curiosity) HOOK_TEXT="¿Por qué ellos crecen y tú no? 🤔" ;;
contradiction) HOOK_TEXT="Publico menos, crezco más 🤷" ;;
esac
fi
echo " Text: $HOOK_TEXT"
# Generate scene description
SCENE=$(generate_scene_prompt "$HOOK_TYPE" "$HOOK_TEXT")
# Build image prompt
PROMPT="TikTok vertical slide (9:16, 768x1376). ${HOOK_TYPE^^} HOOK - must stop the scroll immediately. $ANTI_STOCK. Scene: $SCENE. Style: dramatic, cinematic, slightly surreal but not too abstract. Color palette: $COLOR_DESC. Text overlay: \"$HOOK_TEXT\" in large bold $FONT, white with black outline, positioned at 28% from top. The visual should feel IMPOSSIBLE or ABSURD."
# Generate image
$UV_BIN run "$NANO_SCRIPT" \
--prompt "$PROMPT" \
--filename "$HOOKS_DIR/hook-${HOOK_TYPE}.jpg" \
--resolution 1K 2>/dev/null || echo " ⚠️ Error generating $HOOK_TYPE hook image"
# Save individual hook metadata
echo "{\"type\": \"$HOOK_TYPE\", \"text\": \"$HOOK_TEXT\", \"scene\": \"$SCENE\"}" > "$HOOKS_DIR/hook-${HOOK_TYPE}.json"
echo ""
done
echo "═══════════════════════════════════════════════════════════════"
echo "✅ 3 HOOK VARIANTS GENERATED"
echo "═══════════════════════════════════════════════════════════════"
ls -la "$HOOKS_DIR"/*.jpg 2>/dev/null
echo ""
# Save combined hook metadata
cat > "$HOOKS_DIR/hooks-meta.json" << EOF
{
"generated_at": "$(date -Iseconds)",
"product": "$PRODUCT_NAME",
"niche": "$NICHE",
"language": "$LANGUAGE",
"learnings_applied": $([ -n "$PROMPT_BOOST" ] && echo "true" || echo "false"),
"preferred_type": "$PREFER_TYPE",
"hooks": [
$(cat "$HOOKS_DIR/hook-shock.json" 2>/dev/null || echo '{"type":"shock","text":"","scene":""}'),
$(cat "$HOOKS_DIR/hook-curiosity.json" 2>/dev/null || echo '{"type":"curiosity","text":"","scene":""}'),
$(cat "$HOOKS_DIR/hook-contradiction.json" 2>/dev/null || echo '{"type":"contradiction","text":"","scene":""}')
]
}
EOF
echo "📋 Hook metadata saved to: $HOOKS_DIR/hooks-meta.json"
echo ""
if [ -n "$PREFER_TYPE" ]; then
echo "💡 TIP: Based on learnings, $PREFER_TYPE hooks perform best. Consider using --hook-type $PREFER_TYPE"
fi
echo ""
echo "👉 Next: Review hooks and select best one, then run generate-slides.sh with --hook-type [shock|curiosity|contradiction]"
#!/bin/bash
# Generate slides v4 - Visual novelty + hook categories + anti-stock
set -e
CAROUSEL_DIR="/tmp/carousel"
ANALYSIS_FILE="$CAROUSEL_DIR/analysis.json"
HOOKS_DIR="$CAROUSEL_DIR/hooks"
UV_BIN="${HOME}/.local/bin/uv"
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
NANO_SCRIPT="$SCRIPT_DIR/generate_image.py"
# Parse arguments
HOOK_TYPE="${1:-auto}" # shock, curiosity, contradiction, or auto (random)
if [ ! -f "$ANALYSIS_FILE" ]; then
echo "❌ Error: $ANALYSIS_FILE not found"
echo " Run first: node analyze-web.js <URL>"
exit 1
fi
if [ -z "$GEMINI_API_KEY" ]; then
echo "❌ Error: GEMINI_API_KEY is not set"
exit 1
fi
# Anti-stock filter - CRITICAL for visual novelty
ANTI_STOCK="ABSOLUTELY AVOID: generic stock photo aesthetics, typical influencer poses, shocked pretty girl trope, overused laptop-coffee setups, fake smiles, corporate handshakes, typical business meeting scenes, anything that looks like it came from Shutterstock."
# ═══════════════════════════════════════════════════════════════
# READ ANALYSIS DATA
# ═══════════════════════════════════════════════════════════════
PRODUCT_NAME=$(jq -r '.storytelling.productName // "product"' "$ANALYSIS_FILE")
NICHE=$(jq -r '.storytelling.niche // "business"' "$ANALYSIS_FILE")
UVP=$(jq -r '.storytelling.uvp // "The best solution"' "$ANALYSIS_FILE" | head -c 80)
HEADLINE=$(jq -r '.content.headline // ""' "$ANALYSIS_FILE")
TAGLINE=$(jq -r '.content.tagline // ""' "$ANALYSIS_FILE" | head -c 100)
# Features
FEATURE1=$(jq -r '.content.features[0].title // "Easy to use"' "$ANALYSIS_FILE")
FEATURE2=$(jq -r '.content.features[1].title // "Fast"' "$ANALYSIS_FILE")
FEATURE3=$(jq -r '.content.features[2].title // "Efficient"' "$ANALYSIS_FILE")
# Stats
STAT1=$(jq -r 'if .content.stats[0] then "\(.content.stats[0].value) \(.content.stats[0].label // "")" else "Thousands of users" end' "$ANALYSIS_FILE")
# Visual
COLOR_DESC=$(jq -r '.visualContext.colorDescription // "purple and blue gradient"' "$ANALYSIS_FILE")
FONT=$(jq -r '.visualContext.typography.headingFont // "bold sans-serif"' "$ANALYSIS_FILE")
IMAGE_THEME=$(jq -r '.visualContext.imageThemes[0] // "professional workspace"' "$ANALYSIS_FILE")
STYLE_GUIDE=$(jq -r '.visualContext.styleGuide // "Modern, clean aesthetic"' "$ANALYSIS_FILE")
# Hooks (use the first one)
HOOK=$(jq -r '.storytelling.hooks[0] // "Ready to change?"' "$ANALYSIS_FILE")
# CTA
CTA=$(jq -r '.content.ctas[0] // "Try it free"' "$ANALYSIS_FILE")
echo "═══════════════════════════════════════════════════════════════"
echo "🎨 GENERATING CAROUSEL: $PRODUCT_NAME"
echo "═══════════════════════════════════════════════════════════════"
echo ""
echo "📌 Visual theme: $IMAGE_THEME"
echo "📌 Colors: $COLOR_DESC"
echo "📌 Typography: $FONT"
echo "📌 Hook: $HOOK"
echo ""
# ═══════════════════════════════════════════════════════════════
# STRUCTURED PROMPT TEMPLATE (Nano Banana Pro best practices)
# Using explicit fields produces more consistent results
# ═══════════════════════════════════════════════════════════════
BASE_STYLE="Create an image of: TikTok vertical carousel slide (9:16 ratio, 768x1376 pixels). Style: professional social media content, cinematic, eye-catching, VISUALLY NOVEL. Composition: vertical portrait orientation, text centered at 28%% from top. Lighting: professional, depth of field, soft shadows. Background: $IMAGE_THEME. Color palette: $COLOR_DESC. Text overlay: large bold $FONT font in white with black outline for readability, 4-6 words per line, max 4 lines. $ANTI_STOCK. Avoid: text in bottom 20%% of image, blurry text, cut-off words, horizontal orientation, watermarks, logos."
# Initialize prompts tracking
echo '[]' > "$CAROUSEL_DIR/slide-prompts.json"
generate_slide() {
local NUM=$1
local TEXT=$2
local SCENE=$3
local INPUT_IMAGE=$4
echo "📱 Slide $NUM: $TEXT"
echo " Scene: $SCENE"
if [ -n "$INPUT_IMAGE" ] && [ -f "$INPUT_IMAGE" ]; then
# Image-to-image: STRICT style matching - detect if reference is photo or illustration and match exactly
PROMPT="CRITICAL: Look at the reference image style. If it's a REALISTIC PHOTO, generate a REALISTIC PHOTO. If it's an ILLUSTRATION/CARTOON, generate an ILLUSTRATION/CARTOON. NEVER mix styles. Match EXACTLY: the visual style (photo vs illustration), the artistic technique, color grading, lighting style, and overall aesthetic. Change ONLY: the text to \"$TEXT\" and the background scene to: $SCENE. Keep the SAME person/character style if applicable. Text must be large, bold, white with black outline, centered at 28%% from top. Avoid: text in bottom 20%%, blurry text, cut-off words, switching between photo and illustration styles."
$UV_BIN run "$NANO_SCRIPT" \
--prompt "$PROMPT" \
--filename "$CAROUSEL_DIR/slide-$NUM.jpg" \
--input-image "$INPUT_IMAGE" \
--resolution 1K 2>/dev/null || echo " ⚠️ Error on slide $NUM"
else
# First slide: establish the visual style
PROMPT="$BASE_STYLE. The bold text says: \"$TEXT\". Scene description: $SCENE"
$UV_BIN run "$NANO_SCRIPT" \
--prompt "$PROMPT" \
--filename "$CAROUSEL_DIR/slide-$NUM.jpg" \
--resolution 1K 2>/dev/null || echo " ⚠️ Error on slide $NUM"
fi
# Save prompt for analytics tracking
local ESCAPED_PROMPT=$(echo "$PROMPT" | sed 's/"/\\"/g' | tr '\n' ' ')
local ESCAPED_TEXT=$(echo "$TEXT" | sed 's/"/\\"/g' | tr '\n' ' ')
local TMP_PROMPTS=$(cat "$CAROUSEL_DIR/slide-prompts.json")
echo "$TMP_PROMPTS" | jq --arg slide "$NUM" --arg prompt "$ESCAPED_PROMPT" --arg text "$ESCAPED_TEXT" \
'. += [{"slide": ($slide|tonumber), "text": $text, "prompt": $prompt}]' \
> "$CAROUSEL_DIR/slide-prompts.json"
echo ""
}
echo "═══════════════════════════════════════════════════════════════"
echo "📱 GENERATING 6 SLIDES WITH VISUAL COHERENCE"
echo "═══════════════════════════════════════════════════════════════"
echo ""
# ═══════════════════════════════════════════════════════════════
# GENERATE NICHE-SPECIFIC SLIDE CONTENT
# ═══════════════════════════════════════════════════════════════
# Default pain/agitation text (generic)
PAIN_TEXT="You're still doing everything manually... ⏰ HOURS wasted every week"
PAIN_SCENE="Same person, exhausted, clock showing time passing. Multiple tasks piling up."
AGIT_TEXT="Meanwhile, your competition is automating EVERYTHING 🚀"
AGIT_SCENE="Split screen: competitor succeeding vs you struggling. Growth charts going up for them."
# Override with niche-specific content
case "$NICHE" in
social-media-tools)
PAIN_TEXT="You upload to TikTok... then Instagram... then YouTube... ⏰ HOURS wasted"
PAIN_SCENE="Same person, exhausted, clock showing time passing. Multiple app windows open."
AGIT_TEXT="Meanwhile, your competition is posting on ALL platforms at once 🚀"
AGIT_SCENE="Split screen: competitor succeeding vs you struggling. Growth charts going up for them."
;;
developer-tools)
PAIN_TEXT="Still writing boilerplate code for every integration? 💀"
PAIN_SCENE="Developer frustrated, surrounded by error messages on multiple screens."
AGIT_TEXT="Other devs are shipping 10x faster with the right tools 🚀"
AGIT_SCENE="Split screen: fast developer vs slow developer. Clean code vs messy code."
;;
ecommerce)
PAIN_TEXT="Managing orders across 5 different platforms? 📦 That's exhausting"
PAIN_SCENE="Person overwhelmed with packages, shipping labels, multiple browser tabs."
AGIT_TEXT="Top sellers automated this months ago 🚀"
AGIT_SCENE="Successful store owner relaxing while orders process automatically."
;;
design)
PAIN_TEXT="You know exactly what you want but can't afford a designer 😩"
PAIN_SCENE="Person looking at beautiful rooms on phone, then looking at their plain room."
AGIT_TEXT="Wait until you see what AI can do with just ONE photo 🤯"
AGIT_SCENE="Before and after transformation of a room, dramatic improvement."
;;
health)
PAIN_TEXT="Tracking workouts on paper? Guessing your nutrition? 📝"
PAIN_SCENE="Person confused with handwritten notes, old-school workout tracking."
AGIT_TEXT="People who track properly see 3x better results 💪"
AGIT_SCENE="Fit person confidently using tech vs person struggling with paper notes."
;;
education)
PAIN_TEXT="Still watching 3-hour lectures and remembering nothing? 😴"
PAIN_SCENE="Student falling asleep at desk, surrounded by thick textbooks."
AGIT_TEXT="Smart students are learning 10x faster with AI 🧠"
AGIT_SCENE="Engaged student using modern tools, progress bars filling up."
;;
esac
# ═══════════════════════════════════════════════════════════════
# SLIDE 1: HOOK (establishes ALL visual style)
# Check if pre-generated hooks exist, otherwise generate with category
# ═══════════════════════════════════════════════════════════════
# Select hook type
if [ "$HOOK_TYPE" = "auto" ]; then
# Random selection weighted by learnings (TODO: implement learning weights)
HOOK_TYPES=("shock" "curiosity" "contradiction")
HOOK_TYPE="${HOOK_TYPES[$RANDOM % 3]}"
echo "🎲 Auto-selected hook type: $HOOK_TYPE"
fi
# Check for pre-generated hook
PRE_HOOK="$HOOKS_DIR/hook-${HOOK_TYPE}.jpg"
if [ -f "$PRE_HOOK" ]; then
echo "📎 Using pre-generated $HOOK_TYPE hook"
cp "$PRE_HOOK" "$CAROUSEL_DIR/slide-1.jpg"
# Get hook text from metadata
if [ -f "$HOOKS_DIR/hooks-meta.json" ]; then
HOOK=$(jq -r ".hooks[] | select(.type==\"$HOOK_TYPE\") | .text" "$HOOKS_DIR/hooks-meta.json")
fi
else
echo "🎨 Generating $HOOK_TYPE hook on-the-fly"
# Generate hook based on type with anti-stock and visual novelty
case "$HOOK_TYPE" in
shock)
HOOK_SCENE="Surreal, impossible scene that stops the scroll. Something visually WRONG or ABSURD. Chaotic but eye-catching. Related to $NICHE but exaggerated to the extreme."
;;
curiosity)
HOOK_SCENE="Mysterious, intriguing scene that creates information gap. Something being revealed, secrets, dramatic lighting. Makes viewer NEED to see more."
;;
contradiction)
HOOK_SCENE="Visual contradiction - two things that shouldn't exist together. Relaxed person with intense activity. Impossible combination that makes viewer think 'wait, how?'"
;;
*)
HOOK_SCENE="Frustrated person at desk, overwhelmed, chaotic environment. Stressed expression. Related to $NICHE."
;;
esac
generate_slide 1 "$HOOK" "$HOOK_SCENE"
fi
# Save hook info for tracking/learning (including image prompt for analytics)
# Escape HOOK_SCENE for JSON
HOOK_SCENE_ESCAPED=$(echo "$HOOK_SCENE" | sed 's/"/\\"/g' | tr '\n' ' ')
cat > "$CAROUSEL_DIR/hook-info.json" << EOF
{
"hook_type": "$HOOK_TYPE",
"hook_text": "$HOOK",
"image_prompt": "$HOOK_SCENE_ESCAPED",
"visual_style": "$IMAGE_THEME",
"colors": "$COLOR_DESC",
"generated_at": "$(date -Iseconds)"
}
EOF
# Reference for coherence
REF="$CAROUSEL_DIR/slide-1.jpg"
# SLIDE 2: PROBLEM/PAIN
generate_slide 2 "$PAIN_TEXT" "$PAIN_SCENE" "$REF"
# SLIDE 3: AGITATION (competition)
generate_slide 3 "$AGIT_TEXT" "$AGIT_SCENE" "$REF"
# SLIDE 4: SOLUTION (introduce product)
SOLUTION_TEXT="${PRODUCT_NAME^^}: $TAGLINE"
generate_slide 4 "$SOLUTION_TEXT" "Triumphant moment, relief, solution revealed. Clean interface, simple. One click = everywhere." "$REF"
# SLIDE 5: KEY FEATURE
generate_slide 5 "✓ $FEATURE1" "Clean, professional, benefit-focused. Happy user, productivity boost." "$REF"
# SLIDE 6: CTA
CTA_TEXT="Link in bio 👆 $CTA"
generate_slide 6 "$CTA_TEXT" "Strong call to action, arrow pointing up, urgency, invitation to try." "$REF"
echo "═══════════════════════════════════════════════════════════════"
echo "✅ SLIDES GENERATED"
echo "═══════════════════════════════════════════════════════════════"
echo ""
ls -la "$CAROUSEL_DIR"/*.jpg 2>/dev/null
echo ""
# ═══════════════════════════════════════════════════════════════
# GENERATE OPTIMIZED CAPTION
# ═══════════════════════════════════════════════════════════════
CAPTION="$HOOK
$PAIN_TEXT
The solution: $PRODUCT_NAME ✨
$TAGLINE
✓ $FEATURE1
✓ $FEATURE2
$STAT1 already using it 🔥
👉 Link in bio to try it FREE
#${PRODUCT_NAME//[^a-zA-Z0-9]/} #socialmedia #marketing #contentcreator #viral #fyp #productivity"
echo "📝 SUGGESTED CAPTION:"
echo "───────────────────────────────────────────────────────────────"
echo "$CAPTION"
echo "───────────────────────────────────────────────────────────────"
echo ""
echo "$CAPTION" > "$CAROUSEL_DIR/caption.txt"
echo "💾 Saved to: $CAROUSEL_DIR/caption.txt"
echo "💾 Prompts saved to: $CAROUSEL_DIR/slide-prompts.json"
#!/usr/bin/env node
/**
* Learn from Analytics - Accumulates insights and extracts patterns
*
* What it learns:
* 1. Hook type performance (SHOCK vs CURIOSITY vs CONTRADICTION)
* 2. Best words/phrases that appear in top performers
* 3. Best emojis
* 4. Best hours and days
* 5. Patterns to AVOID (from low performers)
*
* Output: learnings.json that feeds back into generate-hooks.sh
*/
const fs = require('fs');
const path = require('path');
const SKILL_DIR = path.dirname(__dirname);
const LEARNINGS_FILE = path.join(SKILL_DIR, 'learnings.json');
const HOOK_LEDGER_FILE = path.join(SKILL_DIR, 'hook-ledger.json');
const PERFORMANCE_FILE = '/home/node/clawd/tiktok-marketing/hook-performance.json';
// Common words to ignore when extracting patterns
const STOP_WORDS = new Set([
'el', 'la', 'los', 'las', 'un', 'una', 'de', 'del', 'en', 'a', 'que', 'y', 'o', 'tu', 'tus',
'the', 'a', 'an', 'in', 'on', 'at', 'to', 'for', 'of', 'and', 'or', 'your', 'you',
'es', 'son', 'está', 'están', 'con', 'por', 'para', 'como', 'más', 'pero', 'si',
'is', 'are', 'was', 'were', 'be', 'been', 'being', 'have', 'has', 'had', 'do', 'does',
'this', 'that', 'these', 'those', 'my', 'his', 'her', 'its', 'our', 'their',
'al', 'cada', 'todo', 'toda', 'todos', 'todas', 'muy', 'ya', 'se', 'lo', 'le'
]);
function loadLearnings() {
try {
return JSON.parse(fs.readFileSync(LEARNINGS_FILE, 'utf8'));
} catch (e) {
return {
version: 3,
created: new Date().toISOString(),
updated: null,
totalPostsAnalyzed: 0,
// Performance by hook type
hookTypes: {
shock: { posts: 0, totalViews: 0, avgViews: 0, avgEngagement: 0, wins: 0 },
curiosity: { posts: 0, totalViews: 0, avgViews: 0, avgEngagement: 0, wins: 0 },
contradiction: { posts: 0, totalViews: 0, avgViews: 0, avgEngagement: 0, wins: 0 }
},
// Patterns that WORK (from top performers)
winning: {
words: [], // [{word, count, avgViews}]
emojis: [], // [{emoji, count, avgViews}]
phrases: [], // [{phrase, count, avgViews}]
hours: [], // [{hour, count, avgViews}]
days: [] // [{day, count, avgViews}]
},
// Patterns to AVOID (from low performers)
avoid: {
words: [],
emojis: [],
phrases: []
},
// Top 10 best performing hooks (for reference)
topHooks: [],
// Generated recommendations for next hooks
recommendations: {
preferHookType: null,
useWords: [],
useEmojis: [],
avoidWords: [],
bestHour: null,
bestDay: null,
promptBoost: "" // Extra instructions for the AI based on learnings
},
// History of learning updates
history: []
};
}
}
function loadPerformanceData() {
try {
return JSON.parse(fs.readFileSync(PERFORMANCE_FILE, 'utf8'));
} catch (e) {
console.error('❌ Cannot load performance data:', e.message);
return null;
}
}
function loadHookLedger() {
try {
return JSON.parse(fs.readFileSync(HOOK_LEDGER_FILE, 'utf8'));
} catch (e) {
return { hooks: [] };
}
}
function extractEmojis(text) {
if (!text) return [];
const emojiRegex = /[\u{1F300}-\u{1F9FF}]|[\u{2600}-\u{26FF}]|[\u{2700}-\u{27BF}]|[\u{1F600}-\u{1F64F}]|[\u{1F680}-\u{1F6FF}]/gu;
return text.match(emojiRegex) || [];
}
function extractWords(text) {
if (!text) return [];
// Remove emojis and special chars, lowercase, split
const cleaned = text
.replace(/[\u{1F300}-\u{1F9FF}]|[\u{2600}-\u{26FF}]|[\u{2700}-\u{27BF}]|[\u{1F600}-\u{1F64F}]|[\u{1F680}-\u{1F6FF}]/gu, '')
.toLowerCase()
.replace(/[^\w\sáéíóúñü]/g, ' ')
.split(/\s+/)
.filter(w => w.length > 2 && !STOP_WORDS.has(w));
return cleaned;
}
function extractPhrases(text) {
if (!text) return [];
// Extract 2-3 word phrases that might be hooks
const cleaned = text.replace(/[\u{1F300}-\u{1F9FF}]|[\u{2600}-\u{26FF}]/gu, '').trim();
const phrases = [];
// Look for common hook patterns
const patterns = [
/¿[^?]+\?/g, // Spanish questions
/\b\w+\s+\w+\s+\w+(?=\s|$)/g, // 3-word phrases
];
patterns.forEach(pattern => {
const matches = cleaned.match(pattern);
if (matches) phrases.push(...matches.map(m => m.toLowerCase().trim()));
});
return phrases.slice(0, 3); // Max 3 phrases per post
}
function determineHookType(caption, ledgerHooks) {
if (!caption) return 'unknown';
// First try to match with ledger
const ledgerMatch = ledgerHooks.find(h =>
caption.toLowerCase().includes(h.hook_text?.toLowerCase()?.slice(0, 20))
);
if (ledgerMatch?.type) return ledgerMatch.type.toLowerCase();
// Heuristic detection
const lower = caption.toLowerCase();
if (lower.includes('💀') || lower.includes('😱') || lower.includes('error') || lower.includes('muerto')) {
return 'shock';
}
if (lower.includes('🤔') || lower.includes('?') || lower.includes('por qué') || lower.includes('cómo')) {
return 'curiosity';
}
if (lower.includes('🤷') || lower.includes('menos') || lower.includes('but') || lower.includes('pero')) {
return 'contradiction';
}
return 'unknown';
}
function updateWordStats(statsArray, items, views, engagement, isWinning) {
items.forEach(item => {
let existing = statsArray.find(s => s.item === item);
if (!existing) {
existing = { item, count: 0, totalViews: 0, totalEngagement: 0, avgViews: 0, avgEngagement: 0 };
statsArray.push(existing);
}
existing.count++;
existing.totalViews += views;
existing.totalEngagement += engagement;
existing.avgViews = Math.round(existing.totalViews / existing.count);
existing.avgEngagement = (existing.totalEngagement / existing.count).toFixed(2);
});
}
function generateRecommendations(learnings) {
const rec = learnings.recommendations;
// 1. Best hook type
const types = learnings.hookTypes;
const typeRanking = Object.entries(types)
.filter(([_, data]) => data.posts >= 2) // Need at least 2 posts
.sort((a, b) => b[1].avgViews - a[1].avgViews);
if (typeRanking.length > 0) {
rec.preferHookType = typeRanking[0][0];
// Calculate if there's a significant winner (>30% better)
if (typeRanking.length > 1) {
const best = typeRanking[0][1].avgViews;
const second = typeRanking[1][1].avgViews;
if (best > second * 1.3) {
types[typeRanking[0][0]].wins++;
}
}
}
// 2. Best words (top 5 by avgViews with count >= 2)
rec.useWords = learnings.winning.words
.filter(w => w.count >= 2)
.sort((a, b) => b.avgViews - a.avgViews)
.slice(0, 5)
.map(w => w.item);
// 3. Best emojis
rec.useEmojis = learnings.winning.emojis
.filter(e => e.count >= 2)
.sort((a, b) => b.avgViews - a.avgViews)
.slice(0, 3)
.map(e => e.item);
// 4. Words to avoid (from low performers)
rec.avoidWords = learnings.avoid.words
.filter(w => w.count >= 2)
.sort((a, b) => a.avgViews - b.avgViews)
.slice(0, 5)
.map(w => w.item);
// 5. Best hour
const hourStats = learnings.winning.hours
.filter(h => h.count >= 2)
.sort((a, b) => b.avgViews - a.avgViews);
rec.bestHour = hourStats.length > 0 ? hourStats[0].item : null;
// 6. Best day
const dayStats = learnings.winning.days
.filter(d => d.count >= 2)
.sort((a, b) => b.avgViews - a.avgViews);
rec.bestDay = dayStats.length > 0 ? dayStats[0].item : null;
// 7. Generate prompt boost for AI
let boost = [];
if (rec.useWords.length > 0) {
boost.push(`Use these HIGH-PERFORMING words: ${rec.useWords.join(', ')}`);
}
if (rec.useEmojis.length > 0) {
boost.push(`Include these emojis that get engagement: ${rec.useEmojis.join(' ')}`);
}
if (rec.avoidWords.length > 0) {
boost.push(`AVOID these low-performing words: ${rec.avoidWords.join(', ')}`);
}
if (rec.preferHookType) {
boost.push(`Preferred hook style: ${rec.preferHookType.toUpperCase()} (best performing)`);
}
rec.promptBoost = boost.join('. ');
return rec;
}
function main() {
console.log('═══════════════════════════════════════════════════════════════');
console.log('🧠 LEARNING FROM ANALYTICS');
console.log('═══════════════════════════════════════════════════════════════\n');
const perfData = loadPerformanceData();
if (!perfData || !perfData.allPosts) {
console.log('❌ No performance data found. Run check-analytics.sh first.');
process.exit(1);
}
const ledger = loadHookLedger();
const learnings = loadLearnings();
// Filter out test posts (views < 10 or caption too short)
const validPosts = perfData.allPosts.filter(p =>
p.views >= 10 && p.caption && p.caption.length > 20
);
console.log(`📊 Analyzing ${validPosts.length} valid posts (excluded ${perfData.allPosts.length - validPosts.length} test posts)\n`);
if (validPosts.length === 0) {
console.log('❌ No valid posts to analyze.');
process.exit(1);
}
// Calculate average for top/bottom classification
const avgViews = validPosts.reduce((sum, p) => sum + (p.views || 0), 0) / validPosts.length;
const avgEngagement = validPosts.reduce((sum, p) => sum + parseFloat(p.engagementRate || 0), 0) / validPosts.length;
console.log(`📈 Average views: ${Math.round(avgViews)}`);
console.log(`📈 Average engagement: ${avgEngagement.toFixed(2)}%\n`);
// Classify posts
const topPosts = validPosts.filter(p => p.views > avgViews * 1.2); // 20% above average
const lowPosts = validPosts.filter(p => p.views < avgViews * 0.5); // 50% below average
console.log(`🏆 Top performers: ${topPosts.length}`);
console.log(`📉 Low performers: ${lowPosts.length}\n`);
// Reset stats for fresh calculation
learnings.winning.words = [];
learnings.winning.emojis = [];
learnings.winning.hours = [];
learnings.winning.days = [];
learnings.avoid.words = [];
learnings.avoid.emojis = [];
// Reset hook type stats
Object.keys(learnings.hookTypes).forEach(type => {
learnings.hookTypes[type] = { posts: 0, totalViews: 0, avgViews: 0, avgEngagement: 0, wins: learnings.hookTypes[type].wins || 0 };
});
// Process all valid posts
validPosts.forEach(post => {
const views = post.views || 0;
const engagement = parseFloat(post.engagementRate || 0);
const hookType = determineHookType(post.caption, ledger.hooks);
const isTop = views > avgViews * 1.2;
const isLow = views < avgViews * 0.5;
// Update hook type stats
if (hookType !== 'unknown' && learnings.hookTypes[hookType]) {
const typeStats = learnings.hookTypes[hookType];
typeStats.posts++;
typeStats.totalViews += views;
typeStats.avgViews = Math.round(typeStats.totalViews / typeStats.posts);
typeStats.avgEngagement = ((typeStats.avgEngagement * (typeStats.posts - 1) + engagement) / typeStats.posts).toFixed(2);
}
// Extract patterns
const words = extractWords(post.caption);
const emojis = extractEmojis(post.caption);
const hour = post.hour;
const day = post.dayOfWeek;
// Update winning patterns (from top posts)
if (isTop) {
updateWordStats(learnings.winning.words, words, views, engagement, true);
updateWordStats(learnings.winning.emojis, emojis, views, engagement, true);
if (hour !== undefined) updateWordStats(learnings.winning.hours, [hour], views, engagement, true);
if (day) updateWordStats(learnings.winning.days, [day], views, engagement, true);
}
// Update avoid patterns (from low posts)
if (isLow) {
updateWordStats(learnings.avoid.words, words, views, engagement, false);
updateWordStats(learnings.avoid.emojis, emojis, views, engagement, false);
}
});
// Store top hooks for reference
learnings.topHooks = topPosts
.sort((a, b) => b.views - a.views)
.slice(0, 10)
.map(p => ({
caption: p.caption?.slice(0, 100),
views: p.views,
engagement: p.engagementRate,
hour: p.hour,
day: p.dayOfWeek
}));
// Generate recommendations
learnings.totalPostsAnalyzed = validPosts.length;
generateRecommendations(learnings);
// Add to history
learnings.history.push({
date: new Date().toISOString(),
postsAnalyzed: validPosts.length,
avgViews: Math.round(avgViews),
topHookType: learnings.recommendations.preferHookType
});
// Keep only last 10 history entries
if (learnings.history.length > 10) {
learnings.history = learnings.history.slice(-10);
}
// Save
learnings.updated = new Date().toISOString();
fs.writeFileSync(LEARNINGS_FILE, JSON.stringify(learnings, null, 2));
// Print summary
console.log('═══════════════════════════════════════════════════════════════');
console.log('📋 LEARNINGS SUMMARY');
console.log('═══════════════════════════════════════════════════════════════\n');
console.log('🎯 HOOK TYPE PERFORMANCE:');
Object.entries(learnings.hookTypes).forEach(([type, data]) => {
if (data.posts > 0) {
console.log(` ${type.toUpperCase()}: ${data.posts} posts, avg ${data.avgViews} views, ${data.avgEngagement}% eng`);
}
});
console.log('\n✅ WINNING PATTERNS:');
if (learnings.recommendations.useWords.length > 0) {
console.log(` Words: ${learnings.recommendations.useWords.join(', ')}`);
}
if (learnings.recommendations.useEmojis.length > 0) {
console.log(` Emojis: ${learnings.recommendations.useEmojis.join(' ')}`);
}
if (learnings.recommendations.bestHour !== null) {
console.log(` Best hour: ${learnings.recommendations.bestHour}:00 UTC`);
}
console.log('\n❌ PATTERNS TO AVOID:');
if (learnings.recommendations.avoidWords.length > 0) {
console.log(` Words: ${learnings.recommendations.avoidWords.join(', ')}`);
}
console.log('\n💡 RECOMMENDATION:');
console.log(` Prefer: ${learnings.recommendations.preferHookType?.toUpperCase() || 'need more data'} hooks`);
console.log('\n📝 PROMPT BOOST FOR AI:');
console.log(` "${learnings.recommendations.promptBoost || 'Need more data'}"`);
console.log('\n✅ Saved to:', LEARNINGS_FILE);
}
main();
#!/bin/bash
# Publish carousel to TikTok + Instagram via Upload-Post API
# Docs: https://docs.upload-post.com/api/upload-photo
# Usage: ./publish-carousel.sh
set -e
CAROUSEL_DIR="/tmp/carousel"
CAPTION_FILE="$CAROUSEL_DIR/caption.txt"
ANALYSIS_FILE="$CAROUSEL_DIR/analysis.json"
UPLOADPOST_URL="https://api.upload-post.com"
UPLOADPOST_TOKEN="${UPLOADPOST_TOKEN:?Error: UPLOADPOST_TOKEN is not set}"
DEFAULT_USER="${UPLOADPOST_USER:?Error: UPLOADPOST_USER is not set}"
# Verify slides
SLIDES=""
for i in 1 2 3 4 5 6; do
if [ -f "$CAROUSEL_DIR/slide-$i.jpg" ]; then
SLIDES="$SLIDES $CAROUSEL_DIR/slide-$i.jpg"
fi
done
if [ -z "$SLIDES" ]; then
echo "❌ No slides found in $CAROUSEL_DIR/"
exit 1
fi
SLIDE_COUNT=$(echo $SLIDES | wc -w)
echo "═══════════════════════════════════════════════════════════════"
echo "📤 PUBLISHING CAROUSEL TO TIKTOK + INSTAGRAM"
echo "═══════════════════════════════════════════════════════════════"
echo ""
echo "📁 Slides: $SLIDE_COUNT images"
echo "👤 User: $DEFAULT_USER"
# Load caption
if [ -f "$CAPTION_FILE" ]; then
CAPTION=$(cat "$CAPTION_FILE")
else
CAPTION="Check this out! 🔥 #viral #fyp"
fi
# Caption para Instagram (max 2200 chars)
CAPTION_TRUNCATED=$(echo "$CAPTION" | head -c 2000)
# TikTok title (max 90 chars) - first line + hashtags
TIKTOK_TITLE=$(echo "$CAPTION" | head -1 | head -c 60)
TIKTOK_TITLE="$TIKTOK_TITLE #viral #fyp"
echo ""
echo "📝 Caption:"
echo "$CAPTION_TRUNCATED" | head -5
echo "..."
echo ""
# Extract product name from analysis
PRODUCT_NAME="carousel"
if [ -f "$ANALYSIS_FILE" ]; then
PRODUCT_NAME=$(jq -r '.storytelling.productName // "carousel"' "$ANALYSIS_FILE")
fi
# Build curl command per documentation
echo "🚀 Sending to Upload-Post API..."
echo " 🎵 auto_add_music: enabled"
echo " 🌍 privacy_level: PUBLIC_TO_EVERYONE"
echo ""
# Create command
CMD="curl -s -X POST '$UPLOADPOST_URL/api/upload_photos'"
CMD="$CMD -H 'Authorization: Apikey $UPLOADPOST_TOKEN'"
CMD="$CMD -F 'user=$DEFAULT_USER'"
CMD="$CMD -F 'platform[]=tiktok'"
CMD="$CMD -F 'platform[]=instagram'"
CMD="$CMD -F 'title=$CAPTION_TRUNCATED'"
CMD="$CMD -F 'tiktok_title=$TIKTOK_TITLE'"
CMD="$CMD -F 'auto_add_music=true'"
CMD="$CMD -F 'privacy_level=PUBLIC_TO_EVERYONE'"
CMD="$CMD -F 'media_type=IMAGE'"
CMD="$CMD -F 'async_upload=true'"
# Add photos
for slide in $SLIDES; do
CMD="$CMD -F 'photos[]=@$slide'"
done
# Execute
RESPONSE=$(eval $CMD)
echo "📨 API Response:"
echo "$RESPONSE" | jq . 2>/dev/null || echo "$RESPONSE"
# Extraer request_id
REQUEST_ID=$(echo "$RESPONSE" | jq -r '.request_id // empty' 2>/dev/null)
SUCCESS=$(echo "$RESPONSE" | jq -r '.success // false' 2>/dev/null)
if [ "$SUCCESS" = "true" ] && [ -n "$REQUEST_ID" ]; then
echo ""
echo "✅ Upload sent!"
echo " Request ID: $REQUEST_ID"
# Save post info for analytics tracking
POST_INFO="{
\"request_id\": \"$REQUEST_ID\",
\"platforms\": [\"tiktok\", \"instagram\"],
\"product\": \"$PRODUCT_NAME\",
\"slides\": $SLIDE_COUNT,
\"timestamp\": \"$(date -Iseconds)\",
\"user\": \"$DEFAULT_USER\",
\"caption\": $(echo "$CAPTION_TRUNCATED" | jq -Rs .)
}"
echo "$POST_INFO" > "$CAROUSEL_DIR/post-info.json"
echo ""
echo " 🎵 TikTok: auto_add_music enabled ✅"
echo ""
echo " ⚠️ INSTAGRAM: Remember to go to the app and add viral music!"
echo " 1. Open Instagram → Your profile → The post"
echo " 2. Edit → Add music"
echo " 3. Search for a viral song/trending"
echo ""
echo " 📊 Info saved to: $CAROUSEL_DIR/post-info.json"
# ═══════════════════════════════════════════════════════════════
# UPDATE HOOK LEDGER WITH IMAGE PROMPT TRACKING
# ═══════════════════════════════════════════════════════════════
LEDGER_FILE="$(dirname "$0")/../hook-ledger.json"
HOOK_INFO_FILE="$CAROUSEL_DIR/hook-info.json"
if [ -f "$HOOK_INFO_FILE" ] && [ -f "$LEDGER_FILE" ]; then
echo ""
echo " 📒 Updating hook ledger..."
# Read hook info
HOOK_TYPE=$(jq -r '.hook_type // "unknown"' "$HOOK_INFO_FILE")
HOOK_TEXT=$(jq -r '.hook_text // ""' "$HOOK_INFO_FILE")
IMAGE_PROMPT=$(jq -r '.image_prompt // ""' "$HOOK_INFO_FILE")
VISUAL_STYLE=$(jq -r '.visual_style // ""' "$HOOK_INFO_FILE")
COLORS=$(jq -r '.colors // ""' "$HOOK_INFO_FILE")
# Create new entry
NEW_ENTRY=$(jq -n \
--arg date "$(date +%Y-%m-%d)" \
--arg type "$HOOK_TYPE" \
--arg hook_text "$HOOK_TEXT" \
--arg request_id "$REQUEST_ID" \
--arg image_prompt "$IMAGE_PROMPT" \
--arg visual_style "$VISUAL_STYLE" \
--arg colors "$COLORS" \
'{date: $date, type: $type, hook_text: $hook_text, request_id: $request_id, image_prompt: $image_prompt, visual_style: $visual_style, colors: $colors}')
# Append to ledger
jq --argjson entry "$NEW_ENTRY" '.hooks += [$entry]' "$LEDGER_FILE" > "${LEDGER_FILE}.tmp" && \
mv "${LEDGER_FILE}.tmp" "$LEDGER_FILE"
echo " ✅ Ledger updated with image prompt tracking"
fi
else
echo ""
echo "⚠️ Check API response"
fi
echo ""
echo "═══════════════════════════════════════════════════════════════"
#!/usr/bin/env node
/**
* Review generated slides using the AI agent's vision capabilities.
*
* This script prepares slides for review and outputs instructions
* for the agent to verify each slide using its image-to-text model.
*
* Checks: text legibility, correct spelling, visual coherence, image quality.
*/
const fs = require('fs');
const path = require('path');
const CAROUSEL_DIR = '/tmp/carousel';
const ANALYSIS_FILE = path.join(CAROUSEL_DIR, 'analysis.json');
function getSlideExpectations(analysis) {
const productName = analysis?.storytelling?.productName || 'Product';
const hook = analysis?.storytelling?.hooks?.[0] || 'Hook text';
return [
{ slide: 1, type: 'HOOK', expect: hook, check: 'Must grab attention. Text should be large, bold, and fully readable. No words cut off.' },
{ slide: 2, type: 'PROBLEM', expect: 'Pain point / frustration', check: 'Should agitate a pain point. Text must be legible against background.' },
{ slide: 3, type: 'AGITATION', expect: 'Competition / urgency', check: 'Should create urgency. Visual style must match slide 1.' },
{ slide: 4, type: 'SOLUTION', expect: `${productName} reveal`, check: 'Product name must be spelled correctly and prominent. Style consistent with previous slides.' },
{ slide: 5, type: 'FEATURE', expect: 'Key benefit', check: 'Feature text must be accurate and readable. Visual coherence with other slides.' },
{ slide: 6, type: 'CTA', expect: 'Call to action', check: 'CTA must be clear and prominent. "Link in bio" or similar must be visible.' }
];
}
async function main() {
console.log('═══════════════════════════════════════════════════════════════');
console.log('🔍 SLIDE REVIEW - Vision Verification');
console.log('═══════════════════════════════════════════════════════════════\n');
// Load analysis for context
let analysis = {};
try {
analysis = JSON.parse(fs.readFileSync(ANALYSIS_FILE, 'utf8'));
} catch (e) {
console.log('⚠️ Could not load analysis.json - review will use generic expectations.\n');
}
const expectations = getSlideExpectations(analysis);
const results = [];
for (const exp of expectations) {
const slidePath = path.join(CAROUSEL_DIR, `slide-${exp.slide}.jpg`);
if (!fs.existsSync(slidePath)) {
results.push({ ...exp, status: 'MISSING', path: null, sizeMB: 0 });
console.log(`❌ Slide ${exp.slide} (${exp.type}): FILE MISSING`);
continue;
}
const stats = fs.statSync(slidePath);
const sizeMB = (stats.size / 1024 / 1024).toFixed(2);
// Check basic image validity (non-zero size)
if (stats.size < 1000) {
results.push({ ...exp, status: 'CORRUPT', path: slidePath, sizeMB });
console.log(`❌ Slide ${exp.slide} (${exp.type}): File too small (${sizeMB}MB) - likely corrupt`);
continue;
}
results.push({ ...exp, status: 'READY', path: slidePath, sizeMB });
console.log(`✓ Slide ${exp.slide} (${exp.type}): ${sizeMB}MB - ready for vision review`);
}
const readySlides = results.filter(r => r.status === 'READY');
const missingSlides = results.filter(r => r.status !== 'READY');
console.log(`\n📊 Summary: ${readySlides.length}/6 slides ready, ${missingSlides.length} issues\n`);
if (readySlides.length === 0) {
console.log('❌ No slides available for review. Run generate-slides.sh first.');
process.exit(1);
}
// Output instructions for the agent
console.log('═══════════════════════════════════════════════════════════════');
console.log('📋 AGENT: Review each slide using your vision model.');
console.log(' Use view_file or equivalent to see each image and verify:');
console.log('═══════════════════════════════════════════════════════════════\n');
for (const slide of readySlides) {
console.log(`📱 Slide ${slide.slide} (${slide.type}): ${slide.path}`);
console.log(` Expected: ${slide.expect}`);
console.log(` Verify: ${slide.check}`);
console.log('');
}
console.log('Checklist for EVERY slide:');
console.log(' ✓ Text is fully legible (no words cut off at edges)');
console.log(' ✓ Spelling is correct (especially product name)');
console.log(' ✓ Visual style is consistent across all slides');
console.log(' ✓ Image quality is good (not blurry or distorted)');
console.log(' ✓ Text has enough contrast against background');
console.log(' ✓ No text in bottom 20% (TikTok controls area)');
console.log('');
console.log('If any slide fails, regenerate it with generate-slides.sh');
// Save review data for the agent
fs.writeFileSync(
path.join(CAROUSEL_DIR, 'review-pending.json'),
JSON.stringify({
timestamp: new Date().toISOString(),
slides: results,
instructions: 'Use your vision/image-to-text capability to view each slide image and verify the checklist above.'
}, null, 2)
);
console.log(`\n💾 Review data saved to: ${CAROUSEL_DIR}/review-pending.json`);
}
main().catch(console.error);
#!/bin/bash
# Wrapper que carga credenciales y ejecuta viraloop diario
# Usado por el cron job para tener las variables de entorno correctas
set -e
SKILL_DIR="$(dirname "$0")/.."
cd "$SKILL_DIR"
# Cargar credenciales locales
if [ -f "/home/node/clawd/skills/larry/.env.local" ]; then
export $(grep -v '^#' /home/node/clawd/skills/larry/.env.local | xargs)
fi
# También cargar de upload-post si existe
if [ -f "/home/node/clawd/skills/upload-post/.env.local" ]; then
export $(grep -v '^#' /home/node/clawd/skills/upload-post/.env.local | xargs)
fi
echo "✅ Credenciales cargadas"
echo " UPLOADPOST_TOKEN: ${UPLOADPOST_TOKEN:0:20}..."
echo " UPLOADPOST_USER: ${UPLOADPOST_PROFILE:-influencersde}"
echo " GEMINI_API_KEY: ${GEMINI_API_KEY:0:10}..."
# Exportar usuario si no está definido
export UPLOADPOST_USER="${UPLOADPOST_PROFILE:-influencersde}"
echo ""
echo "🚀 Ejecutando viraloop..."
#!/usr/bin/env node
/**
* Search for competitors using multiple search engines
*/
const { chromium } = require('playwright');
const fs = require('fs');
const CAROUSEL_DIR = '/tmp/carousel';
async function searchBing(browser, query) {
const page = await browser.newPage();
try {
const url = `https://www.bing.com/search?q=${encodeURIComponent(query)}`;
await page.goto(url, { timeout: 15000 });
await page.waitForTimeout(2000);
const results = await page.evaluate(() => {
const items = [];
document.querySelectorAll('#b_results .b_algo, .b_algo').forEach(el => {
const titleEl = el.querySelector('h2 a, h2');
const title = titleEl?.textContent?.trim();
const link = titleEl?.href;
const snippet = el.querySelector('.b_caption p, p')?.textContent?.trim();
if (title && items.length < 5) {
items.push({ title, snippet: snippet?.substring(0, 200), link });
}
});
return items;
});
return results;
} finally {
await page.close();
}
}
async function searchDuckDuckGoHTML(browser, query) {
const page = await browser.newPage();
try {
// HTML version without JS
const url = `https://html.duckduckgo.com/html/?q=${encodeURIComponent(query)}`;
await page.goto(url, { timeout: 15000 });
const results = await page.evaluate(() => {
const items = [];
document.querySelectorAll('.result, .web-result').forEach(el => {
const title = el.querySelector('.result__title, .result__a')?.textContent?.trim();
const link = el.querySelector('.result__a')?.href;
const snippet = el.querySelector('.result__snippet')?.textContent?.trim();
if (title && items.length < 5) {
items.push({ title, snippet: snippet?.substring(0, 200), link });
}
});
return items;
});
return results;
} finally {
await page.close();
}
}
async function main() {
const analysisFile = `${CAROUSEL_DIR}/analysis.json`;
let analysis = {};
try {
analysis = JSON.parse(fs.readFileSync(analysisFile, 'utf8'));
} catch (e) {
console.log('Error: analysis.json not found');
process.exit(1);
}
// Get real product name
let productName = analysis.storytelling?.productName || 'Upload-Post';
// If name is generic, use the URL
if (productName.toLowerCase().includes('social media api')) {
productName = analysis.url?.replace(/https?:\/\//, '').split('.')[0] || 'upload-post';
}
console.log(`🔎 SEARCHING INFO ABOUT: ${productName}\n`);
const browser = await chromium.launch({ headless: true });
const queries = [
`${productName} review`,
`${productName} vs alternatives`,
`${productName} competitors`,
`social media posting API alternatives`
];
const allResults = { competitors: [], reviews: [], mentions: [] };
for (const query of queries) {
console.log(` → "${query}"`);
try {
// Intentar Bing primero
let results = await searchBing(browser, query);
// Si no hay resultados, probar DuckDuckGo HTML
if (results.length === 0) {
results = await searchDuckDuckGoHTML(browser, query);
}
console.log(` ${results.length} resultados`);
results.forEach(r => {
if (query.includes('alternative') || query.includes('competitor')) {
if (!allResults.competitors.find(c => c.title === r.title)) {
allResults.competitors.push(r);
}
} else if (query.includes('review')) {
allResults.reviews.push(r);
} else {
allResults.mentions.push(r);
}
});
} catch (e) {
console.log(` ⚠️ Error: ${e.message.substring(0, 50)}`);
}
}
await browser.close();
// Extract competitor names from titles
const competitorNames = [];
const knownCompetitors = ['buffer', 'hootsuite', 'later', 'sprout', 'socialbee', 'publer', 'metricool', 'ayrshare', 'socialbu', 'postiz'];
allResults.competitors.forEach(c => {
const title = c.title.toLowerCase();
knownCompetitors.forEach(comp => {
if (title.includes(comp) && !competitorNames.includes(comp)) {
competitorNames.push(comp);
}
});
});
// Update analysis
analysis.marketResearch = allResults;
analysis.competitors = competitorNames;
// Update storytelling with competitor info
if (competitorNames.length > 0) {
analysis.storytelling.competitorDiff = `Unlike ${competitorNames.slice(0, 2).join(' and ')}, ${productName} offers a direct API with a single call.`;
}
fs.writeFileSync(analysisFile, JSON.stringify(analysis, null, 2));
console.log('\n═══════════════════════════════════════════════════════════════');
console.log('✅ SEARCH COMPLETED');
console.log('═══════════════════════════════════════════════════════════════');
console.log(` Competitors detected: ${competitorNames.join(', ') || 'none'}`);
console.log(` Reviews found: ${allResults.reviews.length}`);
console.log(` Mentions: ${allResults.mentions.length}`);
console.log(`\n💾 Updated: ${analysisFile}`);
}
main().catch(console.error);