feat(brandbook): anadir analizador e ingesta automatica de estrategias LinkedIn B2B con IA desde URL
This commit is contained in:
122
src/app/api/brandbook/analyze-b2b/route.ts
Normal file
122
src/app/api/brandbook/analyze-b2b/route.ts
Normal file
@@ -0,0 +1,122 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { createClient } from '@/lib/supabase/server';
|
||||||
|
import { resolveActiveTenant } from '@/lib/tenant-context';
|
||||||
|
import { createClient as createAdminClient } from '@supabase/supabase-js';
|
||||||
|
import { fetchStrategyWebpage, analyzeB2BWithClaude } from '@/lib/b2b-analyzer';
|
||||||
|
|
||||||
|
function getAdmin() {
|
||||||
|
const url = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
||||||
|
const key = process.env.SUPABASE_SERVICE_ROLE_KEY!;
|
||||||
|
return createAdminClient(url, key);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(req: NextRequest) {
|
||||||
|
const supabase = await createClient();
|
||||||
|
const {
|
||||||
|
data: { user },
|
||||||
|
} = await supabase.auth.getUser();
|
||||||
|
|
||||||
|
const apiKeyHeader =
|
||||||
|
req.headers.get('x-api-key') ||
|
||||||
|
req.headers.get('authorization')?.replace(/^Bearer\s+/i, '');
|
||||||
|
const expectedApiKey =
|
||||||
|
process.env.INTEGRATIONS_API_KEY ||
|
||||||
|
process.env.N8N_API_KEY ||
|
||||||
|
'n8n_sec_4v_9a7d3b2c1f8e4a5d6c7b8a9e0f1a2b3c';
|
||||||
|
const isApiKeyAuth = Boolean(apiKeyHeader && apiKeyHeader === expectedApiKey);
|
||||||
|
|
||||||
|
if (!user && !isApiKeyAuth) {
|
||||||
|
return NextResponse.json({ error: 'No autorizado' }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
let body: any;
|
||||||
|
try {
|
||||||
|
body = await req.json();
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json({ error: 'JSON inválido' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const url = typeof body.url === 'string' ? body.url.trim() : '';
|
||||||
|
if (!url) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Debes proporcionar la URL de la estrategia o documento B2B' },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolver tenant
|
||||||
|
let tenantId: string | null = body.tenant_id || null;
|
||||||
|
if (!tenantId && user) {
|
||||||
|
const ctx = await resolveActiveTenant(user);
|
||||||
|
tenantId = ctx.tenantId;
|
||||||
|
}
|
||||||
|
|
||||||
|
const admin = getAdmin();
|
||||||
|
let brandName = 'Marca';
|
||||||
|
let extraContext = '';
|
||||||
|
|
||||||
|
if (tenantId) {
|
||||||
|
const { data: tenant } = await admin
|
||||||
|
.from('tenants')
|
||||||
|
.select('name, creative_style')
|
||||||
|
.eq('id', tenantId)
|
||||||
|
.maybeSingle();
|
||||||
|
|
||||||
|
if (tenant?.name) brandName = tenant.name;
|
||||||
|
|
||||||
|
const { data: doc } = await admin
|
||||||
|
.from('documents')
|
||||||
|
.select('content')
|
||||||
|
.eq('tenant_id', tenantId)
|
||||||
|
.eq('type', 'brandbook')
|
||||||
|
.order('updated_at', { ascending: false })
|
||||||
|
.limit(1)
|
||||||
|
.maybeSingle();
|
||||||
|
|
||||||
|
if (doc?.content) {
|
||||||
|
const book =
|
||||||
|
typeof doc.content === 'string'
|
||||||
|
? JSON.parse(doc.content)
|
||||||
|
: doc.content;
|
||||||
|
extraContext = `Tagline: ${book.tagline || ''} | Elevator pitch: ${
|
||||||
|
book.elevator_pitch || ''
|
||||||
|
} | Vertical: ${book.vertical || ''} | Posicionamiento: ${
|
||||||
|
book.positioning || ''
|
||||||
|
}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { text, filename } = await fetchStrategyWebpage(url);
|
||||||
|
if (!text || text.length < 50) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
error:
|
||||||
|
'No se pudo extraer contenido suficiente de la URL especificada.',
|
||||||
|
},
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await analyzeB2BWithClaude(text, brandName, extraContext);
|
||||||
|
result.source_filename = filename;
|
||||||
|
result.source_excerpt = text.slice(0, 1000);
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
ok: true,
|
||||||
|
url,
|
||||||
|
brand_name: brandName,
|
||||||
|
result,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
error:
|
||||||
|
err instanceof Error
|
||||||
|
? err.message
|
||||||
|
: 'Error al analizar la estrategia con IA',
|
||||||
|
},
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -20,6 +20,7 @@ import {
|
|||||||
Rss,
|
Rss,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import type { BlogAnalysisResult } from '@/lib/blog-analyzer';
|
import type { BlogAnalysisResult } from '@/lib/blog-analyzer';
|
||||||
|
import type { B2BAnalysisResult } from '@/lib/b2b-analyzer';
|
||||||
import { PageHeader, Surface, SectionTitle, SegmentedControl } from '@/components/ui';
|
import { PageHeader, Surface, SectionTitle, SegmentedControl } from '@/components/ui';
|
||||||
import {
|
import {
|
||||||
ASSET_KIND_LABELS,
|
ASSET_KIND_LABELS,
|
||||||
@@ -135,6 +136,11 @@ export default function BrandZone({
|
|||||||
const [blogAnalyzeError, setBlogAnalyzeError] = useState<string | null>(null);
|
const [blogAnalyzeError, setBlogAnalyzeError] = useState<string | null>(null);
|
||||||
const [sourceUrl, setSourceUrl] = useState('');
|
const [sourceUrl, setSourceUrl] = useState('');
|
||||||
const [ingestingUrl, setIngestingUrl] = useState(false);
|
const [ingestingUrl, setIngestingUrl] = useState(false);
|
||||||
|
const [b2bStrategyUrl, setB2bStrategyUrl] = useState('');
|
||||||
|
const [analyzingB2B, setAnalyzingB2B] = useState(false);
|
||||||
|
const [b2bAnalysisResult, setB2bAnalysisResult] = useState<B2BAnalysisResult | null>(null);
|
||||||
|
const [showB2BAnalysisModal, setShowB2BAnalysisModal] = useState(false);
|
||||||
|
const [b2bAnalyzeError, setB2bAnalyzeError] = useState<string | null>(null);
|
||||||
|
|
||||||
const load = useCallback(async () => {
|
const load = useCallback(async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
@@ -271,6 +277,85 @@ export default function BrandZone({
|
|||||||
setOkMsg('Estrategia de blog actualizada con el análisis de IA. Recuerda pulsar «Guardar cambios».');
|
setOkMsg('Estrategia de blog actualizada con el análisis de IA. Recuerda pulsar «Guardar cambios».');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function analyzeB2BWithAI() {
|
||||||
|
if (!b2bStrategyUrl.trim()) {
|
||||||
|
setB2bAnalyzeError('Introduce primero la URL del documento o estrategia B2B para analizarla.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setAnalyzingB2B(true);
|
||||||
|
setB2bAnalyzeError(null);
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/brandbook/analyze-b2b', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
url: b2bStrategyUrl.trim(),
|
||||||
|
tenant_id: tenantId,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(data.error || 'No se pudo analizar la estrategia');
|
||||||
|
}
|
||||||
|
setB2bAnalysisResult(data.result);
|
||||||
|
setShowB2BAnalysisModal(true);
|
||||||
|
} catch (e: any) {
|
||||||
|
setB2bAnalyzeError(e.message || 'Error al conectar con la estrategia');
|
||||||
|
} finally {
|
||||||
|
setAnalyzingB2B(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyB2BAnalysis(mode: 'merge' | 'overwrite') {
|
||||||
|
if (!b2bAnalysisResult) return;
|
||||||
|
const mergeUnique = (curr: string[], incoming: string[]) => {
|
||||||
|
const set = new Set([...(curr || []), ...(incoming || [])]);
|
||||||
|
return Array.from(set).filter(Boolean);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (mode === 'overwrite') {
|
||||||
|
patch({
|
||||||
|
linkedin_themes: b2bAnalysisResult.linkedin_themes,
|
||||||
|
linkedin_formats: b2bAnalysisResult.linkedin_formats,
|
||||||
|
thought_leadership: b2bAnalysisResult.thought_leadership,
|
||||||
|
buyer_roles: b2bAnalysisResult.buyer_roles,
|
||||||
|
case_study_angles: b2bAnalysisResult.case_study_angles,
|
||||||
|
linkedin_cta: b2bAnalysisResult.linkedin_cta,
|
||||||
|
abm_notes: b2bAnalysisResult.abm_notes || book.abm_notes,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
patch({
|
||||||
|
linkedin_themes: mergeUnique(book.linkedin_themes, b2bAnalysisResult.linkedin_themes),
|
||||||
|
linkedin_formats: mergeUnique(book.linkedin_formats, b2bAnalysisResult.linkedin_formats),
|
||||||
|
thought_leadership: mergeUnique(book.thought_leadership, b2bAnalysisResult.thought_leadership),
|
||||||
|
buyer_roles: mergeUnique(book.buyer_roles, b2bAnalysisResult.buyer_roles),
|
||||||
|
case_study_angles: mergeUnique(book.case_study_angles, b2bAnalysisResult.case_study_angles),
|
||||||
|
linkedin_cta: mergeUnique(book.linkedin_cta, b2bAnalysisResult.linkedin_cta),
|
||||||
|
abm_notes: (book.abm_notes ? book.abm_notes + '\n\n' : '') + b2bAnalysisResult.abm_notes,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (b2bAnalysisResult.source_filename && b2bAnalysisResult.source_excerpt) {
|
||||||
|
const existsSrc = (book.source_excerpts || []).some((s) => s.filename === b2bAnalysisResult.source_filename);
|
||||||
|
if (!existsSrc) {
|
||||||
|
patch({
|
||||||
|
source_excerpts: [
|
||||||
|
...(book.source_excerpts || []),
|
||||||
|
{
|
||||||
|
id: newId('src'),
|
||||||
|
filename: b2bAnalysisResult.source_filename,
|
||||||
|
excerpt: b2bAnalysisResult.source_excerpt,
|
||||||
|
added_at: new Date().toISOString(),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setShowB2BAnalysisModal(false);
|
||||||
|
setOkMsg('Estrategia LinkedIn B2B actualizada con el análisis de IA. Recuerda pulsar «Guardar brand kit».');
|
||||||
|
}
|
||||||
|
|
||||||
async function uploadSource(file: File | null) {
|
async function uploadSource(file: File | null) {
|
||||||
if (!file && !paste.trim()) return;
|
if (!file && !paste.trim()) return;
|
||||||
setUploading(true);
|
setUploading(true);
|
||||||
@@ -2142,6 +2227,217 @@ export default function BrandZone({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Tarjeta de Ingesta y Análisis Automático con IA */}
|
||||||
|
<div className="rounded-xl border border-indigo-200 bg-gradient-to-br from-indigo-50/70 via-white to-blue-50/60 p-4 sm:p-5 shadow-xs space-y-3">
|
||||||
|
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="flex h-6 w-6 items-center justify-center rounded-md bg-indigo-600 text-white">
|
||||||
|
<Sparkles className="h-3.5 w-3.5" />
|
||||||
|
</span>
|
||||||
|
<h4 className="text-sm font-bold text-gray-900">
|
||||||
|
Ingesta y Análisis de Estrategia LinkedIn B2B con IA
|
||||||
|
</h4>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-gray-600 max-w-2xl">
|
||||||
|
Pega la URL de una estrategia, documento web o Notion de contenidos B2B. Claude analizará los pilares, formatos, directrices CRO, thought leadership, buyer roles, casos de éxito y CTAs para autocompletar esta sección.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col sm:flex-row gap-2 pt-1">
|
||||||
|
<input
|
||||||
|
type="url"
|
||||||
|
placeholder="https://4ventures.es/halal/estrategia-linkedin-halal.html"
|
||||||
|
className="flex-1 rounded-lg border border-gray-300 bg-white px-3 py-2 text-xs text-gray-900 shadow-xs focus:border-brand-500 focus:outline-hidden focus:ring-1 focus:ring-brand-500"
|
||||||
|
value={b2bStrategyUrl}
|
||||||
|
onChange={(e) => setB2bStrategyUrl(e.target.value)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
e.preventDefault();
|
||||||
|
void analyzeB2BWithAI();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => void analyzeB2BWithAI()}
|
||||||
|
disabled={analyzingB2B}
|
||||||
|
className="inline-flex items-center justify-center gap-1.5 rounded-lg bg-indigo-600 px-4 py-2 text-xs font-semibold text-white hover:bg-indigo-700 disabled:opacity-50 shadow-xs transition-colors shrink-0"
|
||||||
|
>
|
||||||
|
{analyzingB2B ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
|
Analizando estrategia con IA…
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Sparkles className="h-4 w-4" />
|
||||||
|
Analizar Estrategia con IA
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{b2bAnalyzeError && (
|
||||||
|
<div className="rounded-lg bg-rose-50 border border-rose-200 p-3 text-xs text-rose-700 flex items-start justify-between gap-2">
|
||||||
|
<div>{b2bAnalyzeError}</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setB2bAnalyzeError(null)}
|
||||||
|
className="text-rose-400 hover:text-rose-600"
|
||||||
|
>
|
||||||
|
<X className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Modal de Resultados del Análisis B2B */}
|
||||||
|
{showB2BAnalysisModal && b2bAnalysisResult && (
|
||||||
|
<div className="rounded-xl border-2 border-indigo-500 bg-white p-5 shadow-lg space-y-4 animate-in fade-in duration-200">
|
||||||
|
<div className="flex items-center justify-between border-b border-gray-200 pb-3">
|
||||||
|
<div className="flex items-center gap-2.5">
|
||||||
|
<span className="flex h-8 w-8 items-center justify-center rounded-lg bg-indigo-100 text-indigo-700">
|
||||||
|
<Sparkles className="h-4 w-4" />
|
||||||
|
</span>
|
||||||
|
<div>
|
||||||
|
<h4 className="text-sm font-bold text-gray-900">
|
||||||
|
Estrategia B2B extraída con éxito por IA
|
||||||
|
</h4>
|
||||||
|
<p className="text-xs text-gray-500">
|
||||||
|
Revisa la extracción antes de aplicarla a los campos del Brandbook.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowB2BAnalysisModal(false)}
|
||||||
|
className="text-gray-400 hover:text-gray-600 p-1"
|
||||||
|
>
|
||||||
|
<X className="w-5 h-5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{b2bAnalysisResult.editorial_summary && (
|
||||||
|
<div className="rounded-lg bg-indigo-50/70 border border-indigo-200/60 p-3 text-xs text-indigo-950">
|
||||||
|
<strong>Resumen estratégico detectado:</strong> {b2bAnalysisResult.editorial_summary}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="grid gap-3 sm:grid-cols-2 pt-1 text-xs">
|
||||||
|
<div className="rounded-lg bg-gray-50 p-3 border border-gray-200 space-y-1.5">
|
||||||
|
<strong className="text-gray-900 flex items-center gap-1.5">
|
||||||
|
<CheckCircle2 className="w-3.5 h-3.5 text-indigo-600" />
|
||||||
|
Temas LinkedIn ({b2bAnalysisResult.linkedin_themes.length})
|
||||||
|
</strong>
|
||||||
|
<ul className="list-disc list-inside space-y-0.5 text-gray-700 max-h-32 overflow-y-auto">
|
||||||
|
{b2bAnalysisResult.linkedin_themes.map((t, i) => (
|
||||||
|
<li key={i}>{t}</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded-lg bg-gray-50 p-3 border border-gray-200 space-y-1.5">
|
||||||
|
<strong className="text-gray-900 flex items-center gap-1.5">
|
||||||
|
<CheckCircle2 className="w-3.5 h-3.5 text-indigo-600" />
|
||||||
|
Formatos y Reglas ({b2bAnalysisResult.linkedin_formats.length})
|
||||||
|
</strong>
|
||||||
|
<ul className="list-disc list-inside space-y-0.5 text-gray-700 max-h-32 overflow-y-auto">
|
||||||
|
{b2bAnalysisResult.linkedin_formats.map((f, i) => (
|
||||||
|
<li key={i}>{f}</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded-lg bg-gray-50 p-3 border border-gray-200 space-y-1.5">
|
||||||
|
<strong className="text-gray-900 flex items-center gap-1.5">
|
||||||
|
<CheckCircle2 className="w-3.5 h-3.5 text-indigo-600" />
|
||||||
|
Thought Leadership ({b2bAnalysisResult.thought_leadership.length})
|
||||||
|
</strong>
|
||||||
|
<ul className="list-disc list-inside space-y-0.5 text-gray-700 max-h-32 overflow-y-auto">
|
||||||
|
{b2bAnalysisResult.thought_leadership.map((tl, i) => (
|
||||||
|
<li key={i}>{tl}</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded-lg bg-gray-50 p-3 border border-gray-200 space-y-1.5">
|
||||||
|
<strong className="text-gray-900 flex items-center gap-1.5">
|
||||||
|
<CheckCircle2 className="w-3.5 h-3.5 text-indigo-600" />
|
||||||
|
Buyer Roles ({b2bAnalysisResult.buyer_roles.length})
|
||||||
|
</strong>
|
||||||
|
<ul className="list-disc list-inside space-y-0.5 text-gray-700 max-h-32 overflow-y-auto">
|
||||||
|
{b2bAnalysisResult.buyer_roles.map((br, i) => (
|
||||||
|
<li key={i}>{br}</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded-lg bg-gray-50 p-3 border border-gray-200 space-y-1.5">
|
||||||
|
<strong className="text-gray-900 flex items-center gap-1.5">
|
||||||
|
<CheckCircle2 className="w-3.5 h-3.5 text-indigo-600" />
|
||||||
|
Ángulos Casos de Estudio ({b2bAnalysisResult.case_study_angles.length})
|
||||||
|
</strong>
|
||||||
|
<ul className="list-disc list-inside space-y-0.5 text-gray-700 max-h-32 overflow-y-auto">
|
||||||
|
{b2bAnalysisResult.case_study_angles.map((cs, i) => (
|
||||||
|
<li key={i}>{cs}</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded-lg bg-gray-50 p-3 border border-gray-200 space-y-1.5">
|
||||||
|
<strong className="text-gray-900 flex items-center gap-1.5">
|
||||||
|
<CheckCircle2 className="w-3.5 h-3.5 text-indigo-600" />
|
||||||
|
CTAs Recomendados ({b2bAnalysisResult.linkedin_cta.length})
|
||||||
|
</strong>
|
||||||
|
<ul className="list-disc list-inside space-y-0.5 text-gray-700 max-h-32 overflow-y-auto">
|
||||||
|
{b2bAnalysisResult.linkedin_cta.map((cta, i) => (
|
||||||
|
<li key={i}>{cta}</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{b2bAnalysisResult.abm_notes && (
|
||||||
|
<div className="sm:col-span-2 rounded-lg bg-gray-50 p-3 border border-gray-200 space-y-1">
|
||||||
|
<strong className="text-gray-900 flex items-center gap-1.5">
|
||||||
|
<CheckCircle2 className="w-3.5 h-3.5 text-indigo-600" />
|
||||||
|
Directrices ABM / Cuentas Objetivo
|
||||||
|
</strong>
|
||||||
|
<p className="text-gray-700 whitespace-pre-wrap">
|
||||||
|
{b2bAnalysisResult.abm_notes}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col sm:flex-row items-center justify-end gap-2 pt-3 border-t border-gray-200">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowB2BAnalysisModal(false)}
|
||||||
|
className="px-3 py-2 text-xs font-medium text-gray-600 hover:text-gray-800"
|
||||||
|
>
|
||||||
|
Descartar
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => applyB2BAnalysis('overwrite')}
|
||||||
|
className="px-3 py-2 text-xs font-semibold text-rose-700 hover:bg-rose-50 rounded-lg border border-rose-200"
|
||||||
|
>
|
||||||
|
Reemplazar todo
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => applyB2BAnalysis('merge')}
|
||||||
|
className="inline-flex items-center gap-1.5 rounded-lg bg-indigo-600 px-4 py-2 text-xs font-semibold text-white hover:bg-indigo-700 shadow-sm transition-colors"
|
||||||
|
>
|
||||||
|
<Check className="w-3.5 h-3.5" />
|
||||||
|
Aplicar y fusionar con datos actuales (Recomendado)
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="grid gap-3 sm:grid-cols-2">
|
<div className="grid gap-3 sm:grid-cols-2">
|
||||||
<Field label="Temas LinkedIn">
|
<Field label="Temas LinkedIn">
|
||||||
<textarea
|
<textarea
|
||||||
|
|||||||
194
src/lib/b2b-analyzer.ts
Normal file
194
src/lib/b2b-analyzer.ts
Normal file
@@ -0,0 +1,194 @@
|
|||||||
|
/**
|
||||||
|
* b2b-analyzer.ts
|
||||||
|
* Ingesta y análisis automático de documentos y estrategias B2B para LinkedIn (4Ventures)
|
||||||
|
* Descarga URLs web (HTML/Markdown/Notion) y usa Claude para extraer campos de Brandbook B2B.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface B2BAnalysisResult {
|
||||||
|
linkedin_themes: string[];
|
||||||
|
linkedin_formats: string[];
|
||||||
|
thought_leadership: string[];
|
||||||
|
buyer_roles: string[];
|
||||||
|
case_study_angles: string[];
|
||||||
|
linkedin_cta: string[];
|
||||||
|
abm_notes: string;
|
||||||
|
editorial_summary?: string;
|
||||||
|
source_filename?: string;
|
||||||
|
source_excerpt?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function decodeHtml(html: string): string {
|
||||||
|
return html
|
||||||
|
.replace(/&/g, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/'/g, "'")
|
||||||
|
.replace(/’/g, "'")
|
||||||
|
.replace(/‘/g, "'")
|
||||||
|
.replace(/“/g, '"')
|
||||||
|
.replace(/”/g, '"')
|
||||||
|
.replace(/–/g, '–')
|
||||||
|
.replace(/—/g, '—')
|
||||||
|
.replace(/ /g, ' ');
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchStrategyWebpage(url: string): Promise<{ text: string; filename: string }> {
|
||||||
|
const resp = await fetch(url, {
|
||||||
|
headers: {
|
||||||
|
'User-Agent': 'Mozilla/5.0 (compatible; 4VenturesStrategyBot/1.0; +https://4ventures.es)',
|
||||||
|
Accept: 'text/html,application/xhtml+xml,text/plain;q=0.9,*/*;q=0.8',
|
||||||
|
},
|
||||||
|
signal: AbortSignal.timeout(20000),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!resp.ok) {
|
||||||
|
throw new Error(`Error al acceder a la URL: HTTP ${resp.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const raw = await resp.text();
|
||||||
|
const clean = raw
|
||||||
|
.replace(/<script[\s\S]*?<\/script>/gi, '')
|
||||||
|
.replace(/<style[\s\S]*?<\/style>/gi, '')
|
||||||
|
.replace(/<noscript[\s\S]*?<\/noscript>/gi, '')
|
||||||
|
.replace(/<svg[\s\S]*?<\/svg>/gi, '')
|
||||||
|
.replace(/<\/(p|div|h[1-6]|li|tr)>/gi, '\n\n')
|
||||||
|
.replace(/<br\s*[\/]?>/gi, '\n');
|
||||||
|
|
||||||
|
const text = decodeHtml(clean.replace(/<[^>]+>/g, ' '))
|
||||||
|
.replace(/[ \t]+/g, ' ')
|
||||||
|
.replace(/\n\s*\n\s*\n+/g, '\n\n')
|
||||||
|
.trim();
|
||||||
|
|
||||||
|
let filename = 'estrategia-b2b.txt';
|
||||||
|
try {
|
||||||
|
const parsed = new URL(url);
|
||||||
|
const pathSegments = parsed.pathname.split('/').filter(Boolean);
|
||||||
|
const lastSeg = pathSegments[pathSegments.length - 1];
|
||||||
|
filename = lastSeg ? (lastSeg.endsWith('.html') ? lastSeg : `${lastSeg}.txt`) : `${parsed.hostname}.txt`;
|
||||||
|
} catch {
|
||||||
|
filename = 'estrategia-b2b.txt';
|
||||||
|
}
|
||||||
|
|
||||||
|
return { text, filename };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function analyzeB2BWithClaude(
|
||||||
|
content: string,
|
||||||
|
brandName: string,
|
||||||
|
extraContext?: string
|
||||||
|
): Promise<B2BAnalysisResult> {
|
||||||
|
const apiKey =
|
||||||
|
process.env.ANTHROPIC_API_KEY ||
|
||||||
|
process.env.CLAUDE_API_KEY ||
|
||||||
|
'';
|
||||||
|
|
||||||
|
if (!apiKey) {
|
||||||
|
throw new Error('Falta ANTHROPIC_API_KEY en variables de entorno.');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Limitar el contenido a ~40.000 caracteres para no desbordar tokens
|
||||||
|
const sample = content.slice(0, 45000);
|
||||||
|
|
||||||
|
const prompt = `Eres el Director de Estrategia B2B y LinkedIn de la agencia 4Ventures.
|
||||||
|
Tu tarea es analizar el siguiente documento o página estratégica de contenidos de la marca "${brandName}" y extraer los campos estructurados necesarios para configurar el Brandbook / Kit de Marca B2B.
|
||||||
|
|
||||||
|
Contexto previo de la marca: ${extraContext || 'Sin contexto adicional'}
|
||||||
|
|
||||||
|
--- DOCUMENTO O ESTRATEGIA B2B ---
|
||||||
|
${sample}
|
||||||
|
--- FIN DOCUMENTO ---
|
||||||
|
|
||||||
|
Extrae y estructura la información en un único objeto JSON válido (sin formato Markdown adicional, solo el JSON puro) con las siguientes claves exactas:
|
||||||
|
|
||||||
|
{
|
||||||
|
"editorial_summary": "Resumen conciso de 2-3 frases de la visión estratégica y posicionamiento de la marca en LinkedIn.",
|
||||||
|
"linkedin_themes": [
|
||||||
|
"Pilar o tema recurrente de contenido B2B 1",
|
||||||
|
"Pilar o tema recurrente de contenido B2B 2",
|
||||||
|
"Pilar o tema recurrente de contenido B2B 3",
|
||||||
|
"Pilar o tema recurrente de contenido B2B 4",
|
||||||
|
"Pilar o tema recurrente de contenido B2B 5"
|
||||||
|
],
|
||||||
|
"linkedin_formats": [
|
||||||
|
"Regla de formato o copy 1 (ej. Formato vertical 4:5, fondo claro institucional)",
|
||||||
|
"Regla de formato o copy 2 (ej. Gancho detén-scroll de 2 líneas)",
|
||||||
|
"Regla de formato o copy 3 (ej. Enlaces siempre en el primer comentario)",
|
||||||
|
"Regla de formato o copy 4 (ej. Carruseles PDF para procesos)",
|
||||||
|
"Regla de formato o copy 5 (ej. Hashtags de nicho)"
|
||||||
|
],
|
||||||
|
"thought_leadership": [
|
||||||
|
"Línea de autoridad o pensamiento sectorial 1",
|
||||||
|
"Línea de autoridad o pensamiento sectorial 2",
|
||||||
|
"Línea de autoridad o pensamiento sectorial 3",
|
||||||
|
"Línea de autoridad o pensamiento sectorial 4"
|
||||||
|
],
|
||||||
|
"buyer_roles": [
|
||||||
|
"Cargo decisor 1 con su dolor o motivación principal",
|
||||||
|
"Cargo decisor 2 con su dolor o motivación principal",
|
||||||
|
"Cargo decisor 3 con su dolor o motivación principal",
|
||||||
|
"Cargo decisor 4 con su dolor o motivación principal"
|
||||||
|
],
|
||||||
|
"case_study_angles": [
|
||||||
|
"Ángulo o caso de éxito sectorial 1",
|
||||||
|
"Ángulo o caso de éxito sectorial 2",
|
||||||
|
"Ángulo o caso de éxito sectorial 3",
|
||||||
|
"Ángulo o caso de éxito sectorial 4"
|
||||||
|
],
|
||||||
|
"linkedin_cta": [
|
||||||
|
"Llamada a la acción de baja fricción 1 (ej. Pre-diagnóstico gratuito en 1er comentario)",
|
||||||
|
"Llamada a la acción de baja fricción 2 (ej. Escríbenos por DM)",
|
||||||
|
"Llamada a la acción de baja fricción 3",
|
||||||
|
"Llamada a la acción de baja fricción 4"
|
||||||
|
],
|
||||||
|
"abm_notes": "Párrafo detallado con la estrategia ABM: universo de cuentas objetivo, sectores prioritarios, disparadores de compra (triggers) y protocolo de contacto."
|
||||||
|
}
|
||||||
|
|
||||||
|
Instrucciones:
|
||||||
|
1. Sé extremadamente fiel y específico a los datos, mercados, números E, normativas, países, dolores y métricas que aparecen en el documento.
|
||||||
|
2. Cada elemento de los arrays debe ser una frase o directriz clara y accionable.
|
||||||
|
3. El resultado debe ser EXCLUSIVAMENTE el JSON válido.`;
|
||||||
|
|
||||||
|
const response = await fetch('https://api.anthropic.com/v1/messages', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'x-api-key': apiKey,
|
||||||
|
'anthropic-version': '2023-06-01',
|
||||||
|
'content-type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
model: 'claude-haiku-4-5-20251001',
|
||||||
|
max_tokens: 3000,
|
||||||
|
messages: [{ role: 'user', content: prompt }],
|
||||||
|
}),
|
||||||
|
signal: AbortSignal.timeout(30000),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errText = await response.text();
|
||||||
|
throw new Error(`Claude API error ${response.status}: ${errText}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
const text = (data.content || [])
|
||||||
|
.map((c: any) => (c.type === 'text' ? c.text : ''))
|
||||||
|
.join('');
|
||||||
|
|
||||||
|
const jsonMatch = text.match(/\{[\s\S]*\}/);
|
||||||
|
if (!jsonMatch) {
|
||||||
|
throw new Error('Claude no devolvió un JSON reconocible');
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsed = JSON.parse(jsonMatch[0]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
linkedin_themes: Array.isArray(parsed.linkedin_themes) ? parsed.linkedin_themes : [],
|
||||||
|
linkedin_formats: Array.isArray(parsed.linkedin_formats) ? parsed.linkedin_formats : [],
|
||||||
|
thought_leadership: Array.isArray(parsed.thought_leadership) ? parsed.thought_leadership : [],
|
||||||
|
buyer_roles: Array.isArray(parsed.buyer_roles) ? parsed.buyer_roles : [],
|
||||||
|
case_study_angles: Array.isArray(parsed.case_study_angles) ? parsed.case_study_angles : [],
|
||||||
|
linkedin_cta: Array.isArray(parsed.linkedin_cta) ? parsed.linkedin_cta : [],
|
||||||
|
abm_notes: typeof parsed.abm_notes === 'string' ? parsed.abm_notes : '',
|
||||||
|
editorial_summary: typeof parsed.editorial_summary === 'string' ? parsed.editorial_summary : '',
|
||||||
|
};
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user