Initial commit — Sistema Generador de Guiones V4.0

Pipeline completo: URL → Whisper → GPT-4o → pgvector → Supabase
Frontend Vue 3 + Tailwind, Backend Express + Vercel serverless functions
This commit is contained in:
2026-03-28 16:02:59 -05:00
commit 7695dd0be6
47 changed files with 7552 additions and 0 deletions

189
backend/api/analizar.js Normal file
View File

@ -0,0 +1,189 @@
// ============================================================
// ENDPOINT PRINCIPAL — POST /api/analizar
// Orquesta el pipeline completo:
// URL → Audio → Whisper → GPT-4o → Validar → Embedding → Supabase
// ============================================================
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'
export default async function handler(req, res) {
// Solo aceptar POST
if (req.method !== 'POST') {
return res.status(405).json({ error: 'Método no permitido' })
}
const inicio = Date.now()
// ── Validar body de entrada ───────────────────────────────
const {
url,
niche,
sub_niche,
mercado_objetivo,
idioma = 'es',
cliente_id = null,
proyecto_nombre = null,
competidor_referente = false,
// Métricas manuales (la API no las devuelve)
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.' })
}
let paso = 'inicio'
try {
// ── PASO 1: Extraer audio ─────────────────────────────
paso = 'extraccion'
const { audioUrl, duracion, titulo, thumbnail, plataforma } = await extraerAudio(url)
// ── PASO 2: Transcribir con Whisper ───────────────────
paso = 'transcripcion'
const transcript = await transcribir(audioUrl, idioma)
// ── PASO 3: Analizar con GPT-4o ───────────────────────
paso = 'analisis'
const analisisRaw = await analizarTranscript(transcript, niche, plataforma, duracion)
// ── PASO 4: Validar con Zod ───────────────────────────
paso = 'validacion'
const analisis = validarAnalisis(analisisRaw)
// ── PASO 5: Generar embedding vectorial ───────────────
paso = 'embedding'
const vector = await generarEmbedding(transcript, analisis)
// ── PASO 6: Guardar en Supabase ───────────────────────
paso = 'guardado'
const { data: guion, error: errorSupabase } = await supabase
.from('guiones')
.insert({
// Organización
cliente_id,
niche,
sub_niche,
mercado_objetivo,
idioma,
proyecto_nombre,
competidor_referente,
// Metadata del video
url_origen: url,
plataforma,
duracion_segundos: duracion,
vistas,
likes,
compartidos,
fecha_publicacion,
// Análisis de GPT-4o (campos de storytelling)
estructura_narrativa: analisis.estructura_narrativa,
gancho_tipo: analisis.gancho_tipo,
gancho_texto: analisis.gancho_texto,
gancho_duracion_seg: analisis.gancho_duracion_seg,
desarrollo_tipo: analisis.desarrollo_tipo,
cta_tipo: analisis.cta_tipo,
cta_texto: analisis.cta_texto,
arco_emocional: analisis.arco_emocional,
conflicto_central: analisis.conflicto_central,
resolucion: analisis.resolucion,
pacing_ritmo: analisis.pacing_ritmo,
numero_actos: analisis.numero_actos,
// Cialdini
cialdini_reciprocidad: analisis.cialdini_reciprocidad,
cialdini_escasez: analisis.cialdini_escasez,
cialdini_autoridad: analisis.cialdini_autoridad,
cialdini_consistencia: analisis.cialdini_consistencia,
cialdini_prueba_social: analisis.cialdini_prueba_social,
cialdini_simpatia: analisis.cialdini_simpatia,
cialdini_unidad: analisis.cialdini_unidad,
sesgo_cognitivo: analisis.sesgo_cognitivo,
trigger_emocional: analisis.trigger_emocional,
intensidad_emocional: analisis.intensidad_emocional,
// Neuropublicidad
atencion_visual: analisis.atencion_visual,
lenguaje_sensorial: analisis.lenguaje_sensorial,
contraste_narrativo: analisis.contraste_narrativo,
efecto_novedad: analisis.efecto_novedad,
dolor_placer: analisis.dolor_placer,
personalizacion: analisis.personalizacion,
carga_cognitiva: analisis.carga_cognitiva,
velocidad_locucion: analisis.velocidad_locucion,
uso_musica: analisis.uso_musica,
micro_compromisos: analisis.micro_compromisos,
// Contenido
tema_principal: analisis.tema_principal,
angulo_unico: analisis.angulo_unico,
palabras_clave: analisis.palabras_clave,
transcript,
tono: analisis.tono,
persona_narradora: analisis.persona_narradora,
promesa_explicita: analisis.promesa_explicita,
nivel_especificidad: analisis.nivel_especificidad,
// Métricas (score_engagement lo calcula el trigger de Supabase)
score_virabilidad: analisis.score_virabilidad,
resumen_patron: analisis.resumen_patron,
embedding_vector: `[${vector.join(',')}]`,
// Auditoría
procesado_ok: true,
version_prompt: 'v1.0',
})
.select('id, niche, score_virabilidad, resumen_patron')
.single()
if (errorSupabase) {
throw new Error(`Supabase error: ${errorSupabase.message}`)
}
const duracionTotal = ((Date.now() - inicio) / 1000).toFixed(1)
return res.status(200).json({
ok: true,
guion_id: guion.id,
niche: guion.niche,
score_virabilidad: guion.score_virabilidad,
resumen_patron: guion.resumen_patron,
tiempo_total_seg: parseFloat(duracionTotal),
})
} catch (err) {
console.error(`[analizar] Error en paso "${paso}":`, err.message)
// Guardar el error en Supabase para diagnóstico
if (paso !== 'inicio') {
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(() => {}) // silencioso si falla el insert de error
}
return res.status(500).json({
ok: false,
paso,
error: err.message,
})
}
}

106
backend/lib/analizador.js Normal file
View File

@ -0,0 +1,106 @@
// ============================================================
// ANALIZADOR — GPT-4o
// Prompt maestro multidisciplinario: Storytelling + Cialdini
// + Neuropublicidad → JSON de 55 campos analizables
// ============================================================
import OpenAI from 'openai'
const openai = new OpenAI({ apiKey: process.env.OPENAI_API_KEY })
const PROMPT_SISTEMA = `Eres un experto en ingeniería de guiones para video corto (TikTok, Reels, YouTube Shorts) con especialización en:
- Storytelling y estructura narrativa
- Psicología de la persuasión (Cialdini, sesgos cognitivos)
- Neuropublicidad y neuromarketing
- Marketing de contenidos para múltiples nichos
Tu tarea es analizar la transcripción de un video y devolver un JSON con el análisis completo.
SOLO devuelve el JSON, sin texto adicional, sin markdown, sin explicaciones.`
/**
* @param {string} transcript Texto transcrito por Whisper
* @param {string} niche Nicho del video (ej: "fitness", "finanzas")
* @param {string} plataforma tiktok | reels | shorts
* @param {number} duracion Duración en segundos
* @returns {object} JSON con todos los campos de análisis
*/
export async function analizarTranscript(transcript, niche, plataforma, duracion) {
const promptUsuario = `Analiza este video de ${plataforma} de ${duracion} segundos del nicho "${niche}".
TRANSCRIPCIÓN:
"""
${transcript}
"""
Devuelve EXACTAMENTE este JSON con los valores que correspondan:
{
"estructura_narrativa": "<AIDA|PAS|hero_journey|storybrand|antes_despues|otra>",
"gancho_tipo": "<pregunta|declaracion_shock|dato_estadistica|historia|controversia|promesa_directa>",
"gancho_texto": "<primeras 5-8 palabras del video>",
"gancho_duracion_seg": <número entero estimado>,
"desarrollo_tipo": "<problema_solucion|lista|demostracion|testimonio|tutorial|storytelling_puro>",
"cta_tipo": "<seguir|comentar|compartir|comprar|visitar_link|guardar|ninguno>",
"cta_texto": "<texto exacto del call to action, o null si no hay>",
"arco_emocional": "<emoción inicial> → <emoción media> → <emoción final>",
"conflicto_central": "<el problema o tensión principal que articula el video>",
"resolucion": "<cómo se resuelve o qué promete resolver>",
"pacing_ritmo": "<lento|medio|rapido|variable>",
"numero_actos": <1, 2 o 3>,
"cialdini_reciprocidad": <true|false>,
"cialdini_escasez": <true|false>,
"cialdini_autoridad": <true|false>,
"cialdini_consistencia": <true|false>,
"cialdini_prueba_social": <true|false>,
"cialdini_simpatia": <true|false>,
"cialdini_unidad": <true|false>,
"sesgo_cognitivo": "<nombre del sesgo cognitivo principal, o null>",
"trigger_emocional": "<miedo|esperanza|curiosidad|ira|orgullo|tristeza|sorpresa|humor>",
"intensidad_emocional": <número entero del 1 al 10>,
"atencion_visual": "<zoom_agresivo|corte_rapido|texto_pantalla|cara_camara|broll_dinamico|ninguno>",
"lenguaje_sensorial": <true|false>,
"contraste_narrativo": <true|false>,
"efecto_novedad": <true|false>,
"dolor_placer": "<apela_dolor|apela_placer|ambos>",
"personalizacion": <true|false>,
"carga_cognitiva": "<baja|media|alta>",
"velocidad_locucion": "<lenta|normal|rapida|muy_rapida>",
"uso_musica": <true|false>,
"micro_compromisos": <true|false>,
"tema_principal": "<tema en 1-3 palabras>",
"angulo_unico": "<qué diferencia a este video de otros del mismo tema, en 1 oración>",
"palabras_clave": ["<keyword1>", "<keyword2>", "<keyword3>", "<keyword4>", "<keyword5>"],
"tono": "<educativo|entretenimiento|inspiracional|controversial|informativo|humoristico>",
"persona_narradora": "<primera_persona|segunda_persona|tercera_persona|mixta>",
"promesa_explicita": "<la promesa que hace el video al espectador, en 1 oración>",
"nivel_especificidad": "<generico|especifico|ultra_especifico>",
"score_virabilidad": <número entero del 1 al 100>,
"resumen_patron": "<párrafo de 2-3 oraciones describiendo el patrón ganador de este video>"
}`
const completion = await openai.chat.completions.create({
model: 'gpt-4o',
temperature: 0.2, // baja temperatura para análisis consistente
messages: [
{ role: 'system', content: PROMPT_SISTEMA },
{ role: 'user', content: promptUsuario },
],
})
const contenido = completion.choices[0]?.message?.content?.trim()
if (!contenido) {
throw new Error('GPT-4o devolvió una respuesta vacía')
}
// Limpiar posible markdown que GPT-4o a veces añade
const jsonLimpio = contenido
.replace(/^```json\n?/, '')
.replace(/^```\n?/, '')
.replace(/\n?```$/, '')
.trim()
return JSON.parse(jsonLimpio)
}

40
backend/lib/embeddings.js Normal file
View File

@ -0,0 +1,40 @@
// ============================================================
// EMBEDDINGS — text-embedding-3-small
// Genera el vector semántico para búsqueda con pgvector
// ============================================================
import OpenAI from 'openai'
const openai = new OpenAI({ apiKey: process.env.OPENAI_API_KEY })
/**
* Genera el vector de embedding para un guion.
* Vectorizamos un texto compuesto para capturar tanto el
* contenido como los patrones estructurales del video.
*
* @param {string} transcript Transcripción completa
* @param {object} analisis JSON validado de GPT-4o
* @returns {number[]} Vector de 1536 dimensiones
*/
export async function generarEmbedding(transcript, analisis) {
// Construir el texto a vectorizar combinando campos clave
// Esto hace que la búsqueda semántica encuentre videos similares
// en CONTENIDO y en ESTRUCTURA (no solo en palabras)
const textoParaVectorizar = [
`Tema: ${analisis.tema_principal}`,
`Ángulo: ${analisis.angulo_unico}`,
`Estructura: ${analisis.estructura_narrativa}`,
`Gancho: ${analisis.gancho_texto}`,
`Conflicto: ${analisis.conflicto_central}`,
`Trigger emocional: ${analisis.trigger_emocional}`,
`Promesa: ${analisis.promesa_explicita}`,
`Patrón: ${analisis.resumen_patron}`,
`Transcript: ${transcript.slice(0, 1000)}`, // primeros 1000 chars
].join('\n')
const response = await openai.embeddings.create({
model: 'text-embedding-3-small',
input: textoParaVectorizar,
})
return response.data[0].embedding
}

54
backend/lib/extractor.js Normal file
View File

@ -0,0 +1,54 @@
// ============================================================
// EXTRACTOR — Social Download All In One (RapidAPI)
// Devuelve la URL del audio MP3 y metadata del video
// ============================================================
const RAPIDAPI_HOST = 'social-download-all-in-one.p.rapidapi.com'
const RAPIDAPI_URL = `https://${RAPIDAPI_HOST}/v1/social/autolink`
/**
* @param {string} url URL del video (TikTok, Reels, YouTube Shorts)
* @returns {{ audioUrl: string, duracion: number, titulo: string, thumbnail: string, plataforma: string }}
*/
export async function extraerAudio(url) {
const response = await fetch(RAPIDAPI_URL, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-rapidapi-host': RAPIDAPI_HOST,
'x-rapidapi-key': process.env.RAPIDAPI_KEY,
},
body: JSON.stringify({ url }),
})
if (!response.ok) {
throw new Error(`Social Download API error: ${response.status} ${response.statusText}`)
}
const data = await response.json()
if (data.error) {
throw new Error(`Social Download API devolvió error para la URL: ${url}`)
}
// Buscar el media de tipo audio
const audioMedia = data.medias?.find(m => m.type === 'audio')
if (!audioMedia?.url) {
throw new Error('La API no devolvió un archivo de audio para esta URL')
}
return {
audioUrl: audioMedia.url,
duracion: data.duration ?? null,
titulo: data.title ?? null,
thumbnail: data.thumbnail ?? null,
plataforma: detectarPlataforma(url),
}
}
function detectarPlataforma(url) {
if (url.includes('tiktok.com')) return 'tiktok'
if (url.includes('instagram.com')) return 'reels'
if (url.includes('youtube.com') || url.includes('youtu.be')) return 'shorts'
return 'tiktok' // fallback
}

7
backend/lib/supabase.js Normal file
View File

@ -0,0 +1,7 @@
import { createClient } from '@supabase/supabase-js'
// Service Role key: bypasea RLS, solo usar en backend
export const supabase = createClient(
process.env.SUPABASE_URL,
process.env.SUPABASE_SERVICE_ROLE_KEY
)

View File

@ -0,0 +1,36 @@
// ============================================================
// TRANSCRIPTOR — OpenAI Whisper
// Descarga el audio desde la URL y lo transcribe
// ============================================================
import OpenAI from 'openai'
const openai = new OpenAI({ apiKey: process.env.OPENAI_API_KEY })
/**
* @param {string} audioUrl URL directa del MP3 (de Social Download API)
* @param {string} idioma Código de idioma: 'es', 'en', 'pt', etc.
* @returns {string} Transcripción completa del audio
*/
export async function transcribir(audioUrl, idioma = 'es') {
// Descargar el audio desde la URL del CDN
const audioResponse = await fetch(audioUrl)
if (!audioResponse.ok) {
throw new Error(`Error al descargar audio: ${audioResponse.status}`)
}
const audioBuffer = await audioResponse.arrayBuffer()
const audioFile = new File([audioBuffer], 'audio.mp3', { type: 'audio/mpeg' })
const transcripcion = await openai.audio.transcriptions.create({
file: audioFile,
model: 'whisper-1',
language: idioma === 'otro' ? undefined : idioma, // auto-detect si es 'otro'
response_format: 'text',
})
if (!transcripcion || transcripcion.trim().length < 10) {
throw new Error('Whisper no pudo transcribir el audio (resultado vacío o muy corto)')
}
return transcripcion.trim()
}

91
backend/lib/validador.js Normal file
View File

@ -0,0 +1,91 @@
// ============================================================
// VALIDADOR — Zod Schema
// Valida el JSON de GPT-4o antes de guardar en Supabase
// Si GPT-4o alucina un valor fuera del enum, lo atrapa aquí
// ============================================================
import { z } from 'zod'
const EstructuraEnum = z.enum(['AIDA','PAS','hero_journey','storybrand','antes_despues','otra'])
const GanchoTipoEnum = z.enum(['pregunta','declaracion_shock','dato_estadistica','historia','controversia','promesa_directa'])
const DesarrolloEnum = z.enum(['problema_solucion','lista','demostracion','testimonio','tutorial','storytelling_puro'])
const CtaTipoEnum = z.enum(['seguir','comentar','compartir','comprar','visitar_link','guardar','ninguno'])
const PacingEnum = z.enum(['lento','medio','rapido','variable'])
const TriggerEnum = z.enum(['miedo','esperanza','curiosidad','ira','orgullo','tristeza','sorpresa','humor'])
const AtencionVisualEnum= z.enum(['zoom_agresivo','corte_rapido','texto_pantalla','cara_camara','broll_dinamico','ninguno'])
const DolorPlacerEnum = z.enum(['apela_dolor','apela_placer','ambos'])
const CargaEnum = z.enum(['baja','media','alta'])
const VelocidadEnum = z.enum(['lenta','normal','rapida','muy_rapida'])
const TonoEnum = z.enum(['educativo','entretenimiento','inspiracional','controversial','informativo','humoristico'])
const PersonaEnum = z.enum(['primera_persona','segunda_persona','tercera_persona','mixta'])
const EspecificidadEnum = z.enum(['generico','especifico','ultra_especifico'])
export const AnalisisSchema = z.object({
// Storytelling
estructura_narrativa: EstructuraEnum,
gancho_tipo: GanchoTipoEnum,
gancho_texto: z.string().min(1).max(200),
gancho_duracion_seg: z.number().int().min(0).max(30),
desarrollo_tipo: DesarrolloEnum,
cta_tipo: CtaTipoEnum,
cta_texto: z.string().max(300).nullable(),
arco_emocional: z.string().min(1).max(200),
conflicto_central: z.string().min(1).max(500),
resolucion: z.string().min(1).max(500),
pacing_ritmo: PacingEnum,
numero_actos: z.number().int().min(1).max(4),
// Cialdini
cialdini_reciprocidad: z.boolean(),
cialdini_escasez: z.boolean(),
cialdini_autoridad: z.boolean(),
cialdini_consistencia: z.boolean(),
cialdini_prueba_social: z.boolean(),
cialdini_simpatia: z.boolean(),
cialdini_unidad: z.boolean(),
sesgo_cognitivo: z.string().max(100).nullable(),
trigger_emocional: TriggerEnum,
intensidad_emocional: z.number().int().min(1).max(10),
// Neuropublicidad
atencion_visual: AtencionVisualEnum,
lenguaje_sensorial: z.boolean(),
contraste_narrativo: z.boolean(),
efecto_novedad: z.boolean(),
dolor_placer: DolorPlacerEnum,
personalizacion: z.boolean(),
carga_cognitiva: CargaEnum,
velocidad_locucion: VelocidadEnum,
uso_musica: z.boolean(),
micro_compromisos: z.boolean(),
// Contenido
tema_principal: z.string().min(1).max(100),
angulo_unico: z.string().min(1).max(500),
palabras_clave: z.array(z.string()).min(1).max(10),
tono: TonoEnum,
persona_narradora: PersonaEnum,
promesa_explicita: z.string().min(1).max(500),
nivel_especificidad: EspecificidadEnum,
// Métricas calculadas por GPT-4o
score_virabilidad: z.number().int().min(1).max(100),
resumen_patron: z.string().min(10).max(1000),
})
/**
* Valida el JSON de GPT-4o y lanza error descriptivo si falla
* @param {object} data JSON crudo de GPT-4o
* @returns {object} Datos validados y tipados
*/
export function validarAnalisis(data) {
const resultado = AnalisisSchema.safeParse(data)
if (!resultado.success) {
const errores = resultado.error.errors
.map(e => `${e.path.join('.')}: ${e.message} (recibido: ${JSON.stringify(e.received ?? 'undefined')})`)
.join('\n')
throw new Error(`Validación GPT-4o falló:\n${errores}`)
}
return resultado.data
}

1289
backend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

20
backend/package.json Normal file
View File

@ -0,0 +1,20 @@
{
"name": "guiones-backend",
"version": "1.0.0",
"private": true,
"type": "module",
"engines": {
"node": ">=18"
},
"scripts": {
"dev": "node --watch server.js"
},
"dependencies": {
"@supabase/supabase-js": "^2.39.0",
"cors": "^2.8.5",
"dotenv": "^16.4.1",
"express": "^4.18.2",
"openai": "^4.28.0",
"zod": "^3.22.4"
}
}

190
backend/server.js Normal file
View File

@ -0,0 +1,190 @@
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 } = 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, vistas, likes, compartidos,
tema_principal, resumen_patron
`, { count: 'exact' })
.eq('procesado_ok', true)
.order('fecha_analisis', { ascending: false })
.range(offset, offset + limit - 1)
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}`))

7
backend/vercel.json Normal file
View File

@ -0,0 +1,7 @@
{
"functions": {
"api/*.js": {
"maxDuration": 60
}
}
}