- Vistas y Likes son ahora obligatorios al analizar un video - El generador ordena referencias por likes/vistas reales en lugar del score_virabilidad estimado por GPT-4o - Agrega CLAUDE.md con guía de arquitectura y comandos Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
365 lines
14 KiB
JavaScript
365 lines
14 KiB
JavaScript
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 { generarGuion } from './lib/generador.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,
|
|
contexto_video = '',
|
|
} = 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' })
|
|
if (!vistas || Number(vistas) <= 0) return res.status(400).json({ error: 'El campo "vistas" es requerido y debe ser mayor a 0' })
|
|
if (!likes || Number(likes) <= 0) return res.status(400).json({ error: 'El campo "likes" es requerido y debe ser mayor a 0' })
|
|
|
|
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, contexto_video)
|
|
|
|
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)
|
|
|
|
paso = 'guardado'
|
|
console.log(`[5/5] Guardando en la base de datos...`)
|
|
|
|
const payload = {
|
|
cliente_id, niche, sub_niche, mercado_objetivo, idioma,
|
|
proyecto_nombre, competidor_referente,
|
|
url_origen: url, plataforma, duracion_segundos: duracion,
|
|
vistas: vistas ? Number(vistas) : null,
|
|
likes: likes ? Number(likes) : null,
|
|
compartidos: compartidos ? Number(compartidos) : null,
|
|
fecha_publicacion,
|
|
contexto_video: contexto_video || null,
|
|
...analisis,
|
|
transcript,
|
|
embedding_vector: vector, // Use native array
|
|
procesado_ok: true,
|
|
version_prompt: 'v1.0',
|
|
}
|
|
|
|
const { data: guion, error: errorSupabase } = await supabase
|
|
.from('guiones')
|
|
.insert(payload)
|
|
.select('id, niche, score_virabilidad, resumen_patron')
|
|
.single()
|
|
|
|
if (errorSupabase) {
|
|
console.error('[Supabase] Error al insertar guion:')
|
|
console.error(' Código:', errorSupabase.code)
|
|
console.error(' Mensaje:', errorSupabase.message)
|
|
console.error(' Detalle:', errorSupabase.details)
|
|
console.error(' Pista:', errorSupabase.hint)
|
|
|
|
// Log simple del objeto para detectar campos inválidos en la consola del backend
|
|
console.error('Payload enviado (resumido):', Object.keys(payload).join(', '))
|
|
|
|
throw new Error(`Supabase [${errorSupabase.code}]: ${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 })
|
|
}
|
|
})
|
|
|
|
// ── POST /api/generar ───────────────────────────────────────
|
|
// Genera un guion nuevo a partir de patrones analizados
|
|
app.post('/api/generar', async (req, res) => {
|
|
const {
|
|
niche, tema, audiencia, plataforma,
|
|
duracion_objetivo = 60,
|
|
tono = 'educativo',
|
|
objetivo = 'engagement',
|
|
estructura = 'AIDA',
|
|
instrucciones_extra = '',
|
|
cliente_id = null,
|
|
referencias_ids = [], // IDs de guiones analizados a usar como referencia
|
|
num_referencias = 3, // cuántos guiones top tomar si no se dan IDs explícitos
|
|
} = req.body
|
|
|
|
if (!niche) return res.status(400).json({ error: 'El campo "niche" es requerido' })
|
|
if (!tema) return res.status(400).json({ error: 'El campo "tema" es requerido' })
|
|
if (!audiencia) return res.status(400).json({ error: 'El campo "audiencia" es requerido' })
|
|
|
|
try {
|
|
// Obtener patrones de referencia
|
|
let patrones = []
|
|
|
|
if (referencias_ids.length > 0) {
|
|
// Usar las referencias explícitas del usuario
|
|
const { data } = await supabase
|
|
.from('guiones')
|
|
.select(`
|
|
estructura_narrativa, gancho_tipo, gancho_texto, apertura_exacta,
|
|
tecnica_retencion, trigger_emocional, intensidad_emocional,
|
|
cialdini_reciprocidad, cialdini_escasez, cialdini_autoridad,
|
|
cialdini_consistencia, cialdini_prueba_social, cialdini_simpatia, cialdini_unidad,
|
|
ingredientes_clave, resumen_patron, score_virabilidad
|
|
`)
|
|
.in('id', referencias_ids)
|
|
.eq('procesado_ok', true)
|
|
|
|
patrones = data || []
|
|
} else {
|
|
// Auto-seleccionar los mejores del mismo niche y plataforma
|
|
let query = supabase
|
|
.from('guiones')
|
|
.select(`
|
|
estructura_narrativa, gancho_tipo, gancho_texto, apertura_exacta,
|
|
tecnica_retencion, trigger_emocional, intensidad_emocional,
|
|
cialdini_reciprocidad, cialdini_escasez, cialdini_autoridad,
|
|
cialdini_consistencia, cialdini_prueba_social, cialdini_simpatia, cialdini_unidad,
|
|
ingredientes_clave, resumen_patron, score_virabilidad
|
|
`)
|
|
.eq('procesado_ok', true)
|
|
.eq('niche', niche)
|
|
.order('likes', { ascending: false })
|
|
.order('vistas', { ascending: false })
|
|
.limit(num_referencias)
|
|
|
|
if (plataforma) query = query.eq('plataforma', plataforma)
|
|
|
|
const { data } = await query
|
|
patrones = data || []
|
|
}
|
|
|
|
const guion = await generarGuion({
|
|
niche, tema, audiencia, plataforma, duracion_objetivo,
|
|
tono, objetivo, estructura, instrucciones_extra,
|
|
}, patrones)
|
|
|
|
// Guardar en Supabase
|
|
const { data: guardado, error: errGuardado } = await supabase
|
|
.from('guiones_generados')
|
|
.insert({
|
|
cliente_id,
|
|
niche, tema, audiencia, plataforma,
|
|
duracion_objetivo, tono, objetivo,
|
|
estructura_usada: estructura,
|
|
instrucciones_extra: instrucciones_extra || null,
|
|
referencias_ids: referencias_ids.length > 0 ? referencias_ids : (patrones.map ? null : null),
|
|
titulo_sugerido: guion.titulo_sugerido,
|
|
gancho: guion.gancho,
|
|
desarrollo: guion.desarrollo,
|
|
cta: guion.cta,
|
|
guion_completo: guion.guion_completo,
|
|
variantes_gancho: guion.variantes_gancho,
|
|
tecnicas_aplicadas: guion.tecnicas_aplicadas,
|
|
notas_produccion: guion.notas_produccion,
|
|
duracion_estimada_seg: guion.duracion_estimada_seg,
|
|
score_estimado: guion.score_estimado,
|
|
version_prompt: 'v1.0',
|
|
})
|
|
.select('id')
|
|
.single()
|
|
|
|
if (errGuardado) throw new Error(`Supabase: ${errGuardado.message}`)
|
|
|
|
res.json({ ok: true, guion_id: guardado.id, guion })
|
|
} catch (err) {
|
|
console.error('[generar] Error:', err.message)
|
|
res.status(500).json({ ok: false, error: err.message })
|
|
}
|
|
})
|
|
|
|
// ── GET /api/generados ──────────────────────────────────────
|
|
// Lista guiones generados con paginación
|
|
app.get('/api/generados', async (req, res) => {
|
|
const { niche, cliente_id, page = 1, limit = 20 } = req.query
|
|
const offset = (Number(page) - 1) * Number(limit)
|
|
|
|
let query = supabase
|
|
.from('guiones_generados')
|
|
.select('id, niche, tema, audiencia, plataforma, tono, objetivo, titulo_sugerido, score_estimado, aprobado, fecha_generacion, num_referencias', { count: 'exact' })
|
|
.order('fecha_generacion', { ascending: false })
|
|
.range(offset, offset + Number(limit) - 1)
|
|
|
|
if (niche) query = query.eq('niche', niche)
|
|
if (cliente_id) query = query.eq('cliente_id', cliente_id)
|
|
|
|
const { data, error, count } = await query
|
|
if (error) return res.status(500).json({ error: error.message })
|
|
res.json({ generados: data, total: count, page: Number(page), limit: Number(limit) })
|
|
})
|
|
|
|
// ── GET /api/generados/:id ──────────────────────────────────
|
|
app.get('/api/generados/:id', async (req, res) => {
|
|
const { data, error } = await supabase
|
|
.from('guiones_generados')
|
|
.select('*')
|
|
.eq('id', req.params.id)
|
|
.single()
|
|
|
|
if (error) return res.status(404).json({ error: 'Guion generado no encontrado' })
|
|
res.json(data)
|
|
})
|
|
|
|
app.listen(PORT, () => console.log(`Backend local corriendo en http://localhost:${PORT}`))
|
|
|
|
// ── Middleware global de manejo de errores ───────────────────
|
|
// Captura cualquier error no manejado en los routes de Express
|
|
app.use((err, req, res, _next) => {
|
|
console.error('[Express] Error no manejado:', err.stack || err.message)
|
|
if (!res.headersSent) {
|
|
res.status(500).json({ ok: false, error: err.message || 'Error interno del servidor' })
|
|
}
|
|
})
|
|
|
|
// ── Prevenir Crash por Promesas Rechazadas ───────────────────
|
|
process.on('unhandledRejection', (reason) => {
|
|
console.error('[Node] unhandledRejection:', reason)
|
|
})
|
|
process.on('uncaughtException', (err) => {
|
|
console.error('[Node] uncaughtException:', err.stack || err.message)
|
|
})
|
|
|