import 'dotenv/config' import express from 'express' import cors from 'cors' import { extraerAudio } from './lib/extractor.js' import { transcribir } from './lib/transcriptor.js' import { analizarTranscript } from './lib/analizador.js' import { validarAnalisis } from './lib/validador.js' import { generarEmbedding } from './lib/embeddings.js' import { supabase } from './lib/supabase.js' // ── Validar variables de entorno requeridas ────────────────── const REQUIRED_ENV = ['OPENAI_API_KEY', 'SUPABASE_URL', 'SUPABASE_SERVICE_ROLE_KEY', 'RAPIDAPI_KEY'] const missingVars = REQUIRED_ENV.filter(k => !process.env[k]) if (missingVars.length > 0) { console.error(`\n❌ Variables de entorno faltantes: ${missingVars.join(', ')}\n Configura el archivo backend/.env antes de iniciar.\n`) process.exit(1) } const app = express() const PORT = process.env.PORT || 3001 app.use(cors({ origin: process.env.ALLOWED_ORIGIN || 'http://localhost:5173' })) app.use(express.json()) // ── GET /api/guiones ──────────────────────────────────────── // Lista todos los guiones con paginación y filtros app.get('/api/guiones', async (req, res) => { const { niche, cliente_id, plataforma, page = 1, limit = 20, todos } = req.query const offset = (page - 1) * limit let query = supabase .from('guiones') .select(` id, niche, sub_niche, plataforma, url_origen, gancho_texto, estructura_narrativa, trigger_emocional, tono, score_engagement, score_virabilidad, score_cialdini, fecha_analisis, procesado_ok, error_detalle, vistas, likes, compartidos, tema_principal, resumen_patron `, { count: 'exact' }) .order('fecha_analisis', { ascending: false }) .range(offset, offset + limit - 1) if (!todos) query = query.eq('procesado_ok', true) if (niche) query = query.eq('niche', niche) if (cliente_id) query = query.eq('cliente_id', cliente_id) if (plataforma) query = query.eq('plataforma', plataforma) const { data, error, count } = await query if (error) return res.status(500).json({ error: error.message }) res.json({ guiones: data, total: count, page: Number(page), limit: Number(limit) }) }) // ── GET /api/guiones/:id ──────────────────────────────────── // Detalle completo de un guion app.get('/api/guiones/:id', async (req, res) => { const { data, error } = await supabase .from('guiones') .select('*') .eq('id', req.params.id) .single() if (error) return res.status(404).json({ error: 'Guion no encontrado' }) res.json(data) }) // ── GET /api/nichos ───────────────────────────────────────── // Lista de nichos distintos para el selector del formulario app.get('/api/nichos', async (req, res) => { const { data, error } = await supabase .from('guiones') .select('niche') .eq('procesado_ok', true) if (error) return res.status(500).json({ error: error.message }) const nichos = [...new Set(data.map(r => r.niche))].sort() res.json(nichos) }) // ── GET /api/clientes ─────────────────────────────────────── app.get('/api/clientes', async (req, res) => { const { data, error } = await supabase .from('clientes') .select('id, nombre, industria') .eq('activo', true) .order('nombre') if (error) return res.status(500).json({ error: error.message }) res.json(data) }) // ── GET /api/stats ────────────────────────────────────────── // Estadísticas para el dashboard app.get('/api/stats', async (req, res) => { const { data, error } = await supabase .from('vista_resumen_nichos') .select('*') if (error) return res.status(500).json({ error: error.message }) res.json(data) }) // ── POST /api/analizar ────────────────────────────────────── // Pipeline completo: URL → Audio → Whisper → GPT-4o → Supabase app.post('/api/analizar', async (req, res) => { const { url, niche, sub_niche, mercado_objetivo, idioma = 'es', cliente_id = null, proyecto_nombre = null, competidor_referente = false, vistas = null, likes = null, compartidos = null, fecha_publicacion = null, } = req.body if (!url) return res.status(400).json({ error: 'El campo "url" es requerido' }) if (!niche) return res.status(400).json({ error: 'El campo "niche" es requerido' }) const URL_SOPORTADAS = /^https?:\/\/(www\.)?(tiktok\.com|vm\.tiktok\.com|instagram\.com|youtube\.com|youtu\.be)/ if (!URL_SOPORTADAS.test(url)) { return res.status(400).json({ error: 'URL no soportada. Solo se aceptan TikTok, Instagram Reels y YouTube Shorts.' }) } const inicio = Date.now() let paso = 'inicio' try { paso = 'extraccion' console.log(`[1/5] Extrayendo audio de: ${url}`) const { audioUrl, duracion, plataforma } = await extraerAudio(url) paso = 'transcripcion' console.log(`[2/5] Transcribiendo audio (${duracion}s)...`) const transcript = await transcribir(audioUrl, idioma) paso = 'analisis' console.log(`[3/5] Analizando con GPT-4o...`) const analisisRaw = await analizarTranscript(transcript, niche, plataforma, duracion) paso = 'validacion' console.log(`[4/5] Validando schema...`) const analisis = validarAnalisis(analisisRaw) paso = 'embedding' console.log(`[5/5] Generando embedding y guardando...`) const vector = await generarEmbedding(transcript, analisis) const { data: guion, error: errorSupabase } = await supabase .from('guiones') .insert({ cliente_id, niche, sub_niche, mercado_objetivo, idioma, proyecto_nombre, competidor_referente, url_origen: url, plataforma, duracion_segundos: duracion, vistas, likes, compartidos, fecha_publicacion, ...analisis, transcript, embedding_vector: `[${vector.join(',')}]`, procesado_ok: true, version_prompt: 'v1.0', }) .select('id, niche, score_virabilidad, resumen_patron') .single() if (errorSupabase) throw new Error(`Supabase: ${errorSupabase.message}`) const segundos = ((Date.now() - inicio) / 1000).toFixed(1) console.log(`✓ Completado en ${segundos}s — ID: ${guion.id}`) res.json({ ok: true, guion_id: guion.id, niche: guion.niche, score_virabilidad: guion.score_virabilidad, resumen_patron: guion.resumen_patron, tiempo_total_seg: parseFloat(segundos), }) } catch (err) { console.error(`✗ Error en paso "${paso}":`, err.message) await supabase.from('guiones').insert({ url_origen: url, niche, idioma, cliente_id, procesado_ok: false, error_detalle: `[${paso}] ${err.message}`, version_prompt: 'v1.0', }).catch(() => {}) res.status(500).json({ ok: false, paso, error: err.message }) } }) app.listen(PORT, () => console.log(`Backend local corriendo en http://localhost:${PORT}`))