Files
Generadordeguiones/backend/api/analizar.js
Hanzo_dev b11d57465e fix: resolver FUNCTION_INVOCATION_FAILED y pipeline de Instagram
- transcriptor: restaurar await toFile() — sin él Whisper recibía una
  Promise en vez del archivo y devolvía 400
- transcriptor: detectar MIME type real (m4a para Instagram, mp3 TikTok)
- analizar: normalizar duración (TikTok→ms, Instagram→s float) a entero
  antes de guardar en Supabase y pasar a GPT-4o
- analizar/server: reemplazar .catch() en insert de error por try/catch —
  el builder de Supabase no expone .catch() directamente; el TypeError
  escapaba al outer catch y causaba FUNCTION_INVOCATION_FAILED en Vercel
- validador: fallback de último recurso en enums cuando GPT-4o devuelve
  valor inválido (ej. "ninguno" para desarrollo_tipo)

Probado end-to-end: Instagram Reel → OK en 27s

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-01 10:57:49 -05:00

140 lines
5.3 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)
// Normalizar duración: TikTok devuelve ms (ej. 47416), Instagram devuelve s float (ej. 49.062)
const duracionSeg = duracion ? Math.round(duracion > 1000 ? duracion / 1000 : duracion) : null
// ── 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, duracionSeg, 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: duracionSeg,
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') {
try {
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,
})
}
}