feat(brandbook): anadir analizador e ingesta automatica de estrategias LinkedIn B2B con IA desde URL

This commit is contained in:
Marcos Fargas
2026-10-15 01:34:24 +02:00
parent e4a605bdbd
commit 0ddd5e5907
3 changed files with 612 additions and 0 deletions

View 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 }
);
}
}

View File

@@ -20,6 +20,7 @@ import {
Rss,
} from 'lucide-react';
import type { BlogAnalysisResult } from '@/lib/blog-analyzer';
import type { B2BAnalysisResult } from '@/lib/b2b-analyzer';
import { PageHeader, Surface, SectionTitle, SegmentedControl } from '@/components/ui';
import {
ASSET_KIND_LABELS,
@@ -135,6 +136,11 @@ export default function BrandZone({
const [blogAnalyzeError, setBlogAnalyzeError] = useState<string | null>(null);
const [sourceUrl, setSourceUrl] = useState('');
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 () => {
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».');
}
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) {
if (!file && !paste.trim()) return;
setUploading(true);
@@ -2142,6 +2227,217 @@ export default function BrandZone({
</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">
<Field label="Temas LinkedIn">
<textarea

194
src/lib/b2b-analyzer.ts Normal file
View 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(/&amp;/g, '&')
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&quot;/g, '"')
.replace(/&#039;/g, "'")
.replace(/&#8217;/g, "'")
.replace(/&#8216;/g, "'")
.replace(/&#8220;/g, '"')
.replace(/&#8221;/g, '"')
.replace(/&#8211;/g, '–')
.replace(/&#8212;/g, '—')
.replace(/&nbsp;/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 : '',
};
}