- 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>
135 lines
5.0 KiB
JavaScript
135 lines
5.0 KiB
JavaScript
// ============================================================
|
|
// 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,
|
|
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.' })
|
|
}
|
|
|
|
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, contexto_video)
|
|
|
|
// ── 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 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, // Array nativo para pgvector
|
|
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]:', 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,
|
|
})
|
|
}
|
|
}
|