feat(brandbook): anadir ingesta y analisis automatico de blog con IA en Zona de Marca
This commit is contained in:
124
src/app/api/brandbook/analyze-blog/route.ts
Normal file
124
src/app/api/brandbook/analyze-blog/route.ts
Normal file
@@ -0,0 +1,124 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { createClient } from '@/lib/supabase/server';
|
||||||
|
import { isAgencyRole, userRole } from '@/lib/agency';
|
||||||
|
import { resolveActiveTenant } from '@/lib/tenant-context';
|
||||||
|
import { createClient as createAdminClient } from '@supabase/supabase-js';
|
||||||
|
import { fetchBlogPosts, analyzeBlogWithClaude } from '@/lib/blog-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 del blog corporativo' },
|
||||||
|
{ 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, slug')
|
||||||
|
.eq('id', tenantId)
|
||||||
|
.maybeSingle();
|
||||||
|
|
||||||
|
if (tenant?.name) {
|
||||||
|
brandName = tenant.name;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Buscar si hay brandbook previo para dar contexto a Claude
|
||||||
|
const { data: doc } = await admin
|
||||||
|
.from('documents')
|
||||||
|
.select('content')
|
||||||
|
.eq('tenant_id', tenantId)
|
||||||
|
.eq('type', 'brandbook')
|
||||||
|
.maybeSingle();
|
||||||
|
|
||||||
|
if (doc?.content) {
|
||||||
|
try {
|
||||||
|
const parsed =
|
||||||
|
typeof doc.content === 'string'
|
||||||
|
? JSON.parse(doc.content)
|
||||||
|
: doc.content;
|
||||||
|
const b = parsed.content || parsed;
|
||||||
|
if (b.brand_name) brandName = b.brand_name;
|
||||||
|
if (b.elevator_pitch || b.value_proposition) {
|
||||||
|
extraContext = `Propuesta de valor: ${b.value_proposition || b.elevator_pitch || ''}\nVertical: ${b.vertical || ''}`;
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 1. Extraer artículos de la URL del blog
|
||||||
|
const posts = await fetchBlogPosts(url);
|
||||||
|
|
||||||
|
if (!posts || posts.length === 0) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
error:
|
||||||
|
'No se encontraron artículos en la URL proporcionada. Revisa que sea accesible y contenga posts.',
|
||||||
|
},
|
||||||
|
{ status: 404 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Analizar con Claude
|
||||||
|
const analysis = await analyzeBlogWithClaude(posts, brandName, extraContext);
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
ok: true,
|
||||||
|
url,
|
||||||
|
brand_name: brandName,
|
||||||
|
posts_count: posts.length,
|
||||||
|
result: analysis,
|
||||||
|
});
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error('[analyze-blog] Error:', err);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: err.message || 'Error analizando el blog con IA' },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -13,7 +13,13 @@ import {
|
|||||||
ImageIcon,
|
ImageIcon,
|
||||||
Type,
|
Type,
|
||||||
Palette,
|
Palette,
|
||||||
|
Globe,
|
||||||
|
ExternalLink,
|
||||||
|
X,
|
||||||
|
Check,
|
||||||
|
Rss,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
|
import type { BlogAnalysisResult } from '@/lib/blog-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,
|
||||||
@@ -123,6 +129,10 @@ export default function BrandZone({
|
|||||||
const [exists, setExists] = useState(false);
|
const [exists, setExists] = useState(false);
|
||||||
const [kitKind, setKitKind] = useState<BrandAssetKind>('logo');
|
const [kitKind, setKitKind] = useState<BrandAssetKind>('logo');
|
||||||
const [kitRole, setKitRole] = useState('primary');
|
const [kitRole, setKitRole] = useState('primary');
|
||||||
|
const [analyzingBlog, setAnalyzingBlog] = useState(false);
|
||||||
|
const [blogAnalysisResult, setBlogAnalysisResult] = useState<BlogAnalysisResult | null>(null);
|
||||||
|
const [showBlogAnalysisModal, setShowBlogAnalysisModal] = useState(false);
|
||||||
|
const [blogAnalyzeError, setBlogAnalyzeError] = useState<string | null>(null);
|
||||||
|
|
||||||
const load = useCallback(async () => {
|
const load = useCallback(async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
@@ -202,6 +212,63 @@ export default function BrandZone({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function handleAnalyzeBlog() {
|
||||||
|
const blogUrl = book.channel_handles?.blog?.trim();
|
||||||
|
if (!blogUrl) {
|
||||||
|
setBlogAnalyzeError('Introduce primero la URL del blog corporativo para poder analizar sus posts.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setAnalyzingBlog(true);
|
||||||
|
setBlogAnalyzeError(null);
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/brandbook/analyze-blog', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
url: blogUrl,
|
||||||
|
tenant_id: tenantId,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(data.error || 'No se pudo analizar el blog');
|
||||||
|
}
|
||||||
|
setBlogAnalysisResult(data.result);
|
||||||
|
setShowBlogAnalysisModal(true);
|
||||||
|
} catch (e: any) {
|
||||||
|
setBlogAnalyzeError(e.message || 'Error al conectar con el blog');
|
||||||
|
} finally {
|
||||||
|
setAnalyzingBlog(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyBlogAnalysis(mode: 'merge' | 'overwrite') {
|
||||||
|
if (!blogAnalysisResult) return;
|
||||||
|
if (mode === 'overwrite') {
|
||||||
|
patch({
|
||||||
|
blog_themes: blogAnalysisResult.blog_themes,
|
||||||
|
blog_seo_keywords: blogAnalysisResult.blog_seo_keywords,
|
||||||
|
content_clusters: blogAnalysisResult.content_clusters,
|
||||||
|
blog_cta: blogAnalysisResult.blog_cta,
|
||||||
|
blog_cadence: blogAnalysisResult.blog_cadence || book.blog_cadence,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
const mergeUnique = (curr: string[], incoming: string[]) => {
|
||||||
|
const set = new Set([...(curr || []), ...(incoming || [])]);
|
||||||
|
return Array.from(set).filter(Boolean);
|
||||||
|
};
|
||||||
|
patch({
|
||||||
|
blog_themes: mergeUnique(book.blog_themes, blogAnalysisResult.blog_themes),
|
||||||
|
blog_seo_keywords: mergeUnique(book.blog_seo_keywords, blogAnalysisResult.blog_seo_keywords),
|
||||||
|
content_clusters: mergeUnique(book.content_clusters, blogAnalysisResult.content_clusters),
|
||||||
|
blog_cta: mergeUnique(book.blog_cta, blogAnalysisResult.blog_cta),
|
||||||
|
blog_cadence: book.blog_cadence || blogAnalysisResult.blog_cadence,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
setShowBlogAnalysisModal(false);
|
||||||
|
setOkMsg('Estrategia de blog actualizada con el análisis de IA. Recuerda pulsar «Guardar cambios».');
|
||||||
|
}
|
||||||
|
|
||||||
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);
|
||||||
@@ -2073,51 +2140,296 @@ export default function BrandZone({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{section === 'blog' && (
|
{section === 'blog' && (
|
||||||
<div className="grid gap-3 sm:grid-cols-2">
|
<div className="space-y-4">
|
||||||
<Field label="Temas blog">
|
{/* Tarjeta de Ingesta y Análisis Automático con IA */}
|
||||||
<textarea
|
<div className="rounded-xl border border-brand-200 bg-gradient-to-br from-brand-50/70 via-white to-sky-50/60 p-4 sm:p-5 shadow-xs space-y-3">
|
||||||
className={areaCls}
|
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-3">
|
||||||
value={listToLines(book.blog_themes)}
|
<div className="space-y-1">
|
||||||
onChange={(e) =>
|
<div className="flex items-center gap-2">
|
||||||
patch({ blog_themes: linesToList(e.target.value) })
|
<span className="flex h-7 w-7 items-center justify-center rounded-lg bg-brand-600 text-white shadow-xs">
|
||||||
}
|
<Globe className="h-4 w-4" />
|
||||||
/>
|
</span>
|
||||||
</Field>
|
<h4 className="text-sm font-bold text-gray-900">
|
||||||
<Field label="Keywords SEO">
|
Ingesta y Análisis Automático del Blog con IA
|
||||||
<textarea
|
</h4>
|
||||||
className={areaCls}
|
<span className="rounded-full bg-brand-100 px-2.5 py-0.5 text-[10px] font-semibold text-brand-700">
|
||||||
value={listToLines(book.blog_seo_keywords)}
|
Claude + Crawler
|
||||||
onChange={(e) =>
|
</span>
|
||||||
patch({ blog_seo_keywords: linesToList(e.target.value) })
|
</div>
|
||||||
}
|
<p className="text-xs text-gray-600">
|
||||||
/>
|
Indica la URL de tu blog corporativo o feed. Nuestro agente rastreará los posts publicados y utilizará Claude para extraer automáticamente clusters temáticos, keywords SEO tratadas, temas recurrentes y CTAs.
|
||||||
</Field>
|
</p>
|
||||||
<Field label="Clusters de contenido">
|
</div>
|
||||||
<textarea
|
</div>
|
||||||
className={areaCls}
|
|
||||||
value={listToLines(book.content_clusters)}
|
<div className="flex flex-col sm:flex-row items-stretch sm:items-end gap-2.5 pt-1">
|
||||||
onChange={(e) =>
|
<div className="flex-1 space-y-1">
|
||||||
patch({ content_clusters: linesToList(e.target.value) })
|
<label className="text-xs font-semibold text-gray-800 flex items-center justify-between">
|
||||||
}
|
<span className="flex items-center gap-1.5">
|
||||||
/>
|
<Globe className="w-3.5 h-3.5 text-brand-600" />
|
||||||
</Field>
|
URL del Blog Corporativo (o Feed RSS)
|
||||||
<Field label="CTAs del blog">
|
</span>
|
||||||
<textarea
|
{book.channel_handles?.blog && (
|
||||||
className={areaCls}
|
<a
|
||||||
value={listToLines(book.blog_cta)}
|
href={book.channel_handles.blog}
|
||||||
onChange={(e) =>
|
target="_blank"
|
||||||
patch({ blog_cta: linesToList(e.target.value) })
|
rel="noopener noreferrer"
|
||||||
}
|
className="text-[11px] font-normal text-brand-600 hover:text-brand-800 flex items-center gap-1"
|
||||||
/>
|
>
|
||||||
</Field>
|
Visitar blog
|
||||||
<Field label="Cadencia">
|
<ExternalLink className="w-3 h-3" />
|
||||||
<input
|
</a>
|
||||||
className={inputCls}
|
)}
|
||||||
value={book.blog_cadence}
|
</label>
|
||||||
onChange={(e) => patch({ blog_cadence: e.target.value })}
|
<input
|
||||||
placeholder="ej. 2 posts/mes"
|
className={inputCls}
|
||||||
/>
|
value={book.channel_handles?.blog || ''}
|
||||||
</Field>
|
onChange={(e) =>
|
||||||
|
patch({
|
||||||
|
channel_handles: {
|
||||||
|
...book.channel_handles,
|
||||||
|
blog: e.target.value,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
placeholder="https://blueparrotschool.com/blog/"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleAnalyzeBlog}
|
||||||
|
disabled={analyzingBlog || !book.channel_handles?.blog}
|
||||||
|
className="inline-flex items-center justify-center gap-2 rounded-lg bg-brand-600 px-4 py-2 text-xs font-semibold text-white shadow-sm hover:bg-brand-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors shrink-0 h-[38px]"
|
||||||
|
>
|
||||||
|
{analyzingBlog ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
|
Analizando posts con IA…
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Sparkles className="h-4 w-4" />
|
||||||
|
Analizar Blog con IA
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{blogAnalyzeError && (
|
||||||
|
<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>{blogAnalyzeError}</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setBlogAnalyzeError(null)}
|
||||||
|
className="text-rose-400 hover:text-rose-600"
|
||||||
|
>
|
||||||
|
<X className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Modal / Panel de Resultados del Análisis de IA */}
|
||||||
|
{showBlogAnalysisModal && blogAnalysisResult && (
|
||||||
|
<div className="rounded-xl border-2 border-brand-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-emerald-100 text-emerald-700">
|
||||||
|
<Sparkles className="h-4 w-4" />
|
||||||
|
</span>
|
||||||
|
<div>
|
||||||
|
<h4 className="text-sm font-bold text-gray-900">
|
||||||
|
Resultados del Análisis IA: {blogAnalysisResult.posts_analyzed} artículos analizados
|
||||||
|
</h4>
|
||||||
|
<p className="text-xs text-gray-500">
|
||||||
|
Claude ha identificado la siguiente estrategia editorial a partir de tus publicaciones reales.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowBlogAnalysisModal(false)}
|
||||||
|
className="text-gray-400 hover:text-gray-600 p-1"
|
||||||
|
>
|
||||||
|
<X className="w-5 h-5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{blogAnalysisResult.editorial_summary && (
|
||||||
|
<div className="rounded-lg bg-brand-50/70 border border-brand-200/60 p-3 text-xs text-brand-950">
|
||||||
|
<strong>Resumen editorial detectado:</strong> {blogAnalysisResult.editorial_summary}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{blogAnalysisResult.posts_sample && blogAnalysisResult.posts_sample.length > 0 && (
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<span className="text-[11px] font-bold text-gray-700 uppercase tracking-wider">
|
||||||
|
Muestra de artículos leídos:
|
||||||
|
</span>
|
||||||
|
<div className="flex flex-wrap gap-1.5 max-h-24 overflow-y-auto">
|
||||||
|
{blogAnalysisResult.posts_sample.map((p, i) => (
|
||||||
|
<span
|
||||||
|
key={i}
|
||||||
|
className="inline-flex items-center gap-1 rounded-md bg-gray-100 px-2 py-0.5 text-[11px] text-gray-700 border border-gray-200"
|
||||||
|
title={p.title}
|
||||||
|
>
|
||||||
|
{p.category && (
|
||||||
|
<strong className="text-brand-700 font-medium">[{p.category}]</strong>
|
||||||
|
)}
|
||||||
|
<span className="truncate max-w-[240px]">{p.title}</span>
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</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-brand-600" />
|
||||||
|
Clusters de contenido ({blogAnalysisResult.content_clusters.length})
|
||||||
|
</strong>
|
||||||
|
<ul className="list-disc list-inside space-y-0.5 text-gray-700">
|
||||||
|
{blogAnalysisResult.content_clusters.map((c, i) => (
|
||||||
|
<li key={i}>{c}</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-brand-600" />
|
||||||
|
Keywords SEO ({blogAnalysisResult.blog_seo_keywords.length})
|
||||||
|
</strong>
|
||||||
|
<div className="flex flex-wrap gap-1">
|
||||||
|
{blogAnalysisResult.blog_seo_keywords.map((kw, i) => (
|
||||||
|
<span key={i} className="rounded bg-white px-1.5 py-0.5 border border-gray-200 text-gray-700">
|
||||||
|
{kw}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</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-brand-600" />
|
||||||
|
Temas recurrentes ({blogAnalysisResult.blog_themes.length})
|
||||||
|
</strong>
|
||||||
|
<ul className="list-disc list-inside space-y-0.5 text-gray-700">
|
||||||
|
{blogAnalysisResult.blog_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-brand-600" />
|
||||||
|
CTAs recomendados ({blogAnalysisResult.blog_cta.length})
|
||||||
|
</strong>
|
||||||
|
<ul className="list-disc list-inside space-y-0.5 text-gray-700">
|
||||||
|
{blogAnalysisResult.blog_cta.map((cta, i) => (
|
||||||
|
<li key={i}>{cta}</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
{blogAnalysisResult.blog_cadence && (
|
||||||
|
<div className="pt-2 text-[11px] text-gray-600 border-t border-gray-200">
|
||||||
|
<strong>Cadencia estimada:</strong> {blogAnalysisResult.blog_cadence}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap items-center justify-end gap-2.5 pt-3 border-t border-gray-200">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowBlogAnalysisModal(false)}
|
||||||
|
className="rounded-lg border border-gray-300 bg-white px-3.5 py-2 text-xs font-semibold text-gray-700 hover:bg-gray-50 shadow-xs"
|
||||||
|
>
|
||||||
|
Descartar
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => applyBlogAnalysis('overwrite')}
|
||||||
|
className="rounded-lg border border-amber-300 bg-amber-50 px-3.5 py-2 text-xs font-semibold text-amber-800 hover:bg-amber-100 shadow-xs"
|
||||||
|
>
|
||||||
|
Reemplazar todo
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => applyBlogAnalysis('merge')}
|
||||||
|
className="inline-flex items-center gap-1.5 rounded-lg bg-emerald-600 px-4 py-2 text-xs font-semibold text-white hover:bg-emerald-700 shadow-sm transition-colors"
|
||||||
|
>
|
||||||
|
<Check className="w-3.5 h-3.5" />
|
||||||
|
Aplicar y fusionar con datos actuales (Recomendado)
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Campos Editables del Blog */}
|
||||||
|
<div className="grid gap-3 sm:grid-cols-2 pt-2">
|
||||||
|
<Field
|
||||||
|
label="Temas blog"
|
||||||
|
hint="Líneas editoriales y temas que se tratan periódicamente (uno por línea)"
|
||||||
|
>
|
||||||
|
<textarea
|
||||||
|
className={areaCls}
|
||||||
|
value={listToLines(book.blog_themes)}
|
||||||
|
onChange={(e) =>
|
||||||
|
patch({ blog_themes: linesToList(e.target.value) })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Field
|
||||||
|
label="Keywords SEO"
|
||||||
|
hint="Palabras clave principales a posicionar en las entradas (una por línea)"
|
||||||
|
>
|
||||||
|
<textarea
|
||||||
|
className={areaCls}
|
||||||
|
value={listToLines(book.blog_seo_keywords)}
|
||||||
|
onChange={(e) =>
|
||||||
|
patch({ blog_seo_keywords: linesToList(e.target.value) })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Field
|
||||||
|
label="Clusters de contenido"
|
||||||
|
hint="Pilares temáticos o silos que agrupan los artículos (uno por línea)"
|
||||||
|
>
|
||||||
|
<textarea
|
||||||
|
className={areaCls}
|
||||||
|
value={listToLines(book.content_clusters)}
|
||||||
|
onChange={(e) =>
|
||||||
|
patch({ content_clusters: linesToList(e.target.value) })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Field
|
||||||
|
label="CTAs del blog"
|
||||||
|
hint="Llamadas a la acción al final o dentro de los posts (uno por línea)"
|
||||||
|
>
|
||||||
|
<textarea
|
||||||
|
className={areaCls}
|
||||||
|
value={listToLines(book.blog_cta)}
|
||||||
|
onChange={(e) =>
|
||||||
|
patch({ blog_cta: linesToList(e.target.value) })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Field
|
||||||
|
label="Cadencia"
|
||||||
|
hint="Frecuencia habitual de publicación en el blog"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
className={inputCls}
|
||||||
|
value={book.blog_cadence}
|
||||||
|
onChange={(e) => patch({ blog_cadence: e.target.value })}
|
||||||
|
placeholder="ej. 2 posts/mes"
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
501
src/lib/blog-analyzer.ts
Normal file
501
src/lib/blog-analyzer.ts
Normal file
@@ -0,0 +1,501 @@
|
|||||||
|
/**
|
||||||
|
* blog-analyzer.ts
|
||||||
|
* Ingesta y análisis automático de blogs corporativos para Zona de Marca (4Ventures)
|
||||||
|
* Multi-estrategia: WP REST API -> Feeds RSS/Atom -> Scraping semántico HTML -> Análisis con Claude
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface ExtractedBlogPost {
|
||||||
|
title: string;
|
||||||
|
link: string;
|
||||||
|
excerpt: string;
|
||||||
|
category?: string;
|
||||||
|
date?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BlogAnalysisResult {
|
||||||
|
content_clusters: string[];
|
||||||
|
blog_seo_keywords: string[];
|
||||||
|
blog_themes: string[];
|
||||||
|
blog_cta: string[];
|
||||||
|
blog_cadence: string;
|
||||||
|
editorial_summary: string;
|
||||||
|
posts_analyzed: number;
|
||||||
|
posts_sample: Array<{
|
||||||
|
title: string;
|
||||||
|
category?: string;
|
||||||
|
date?: string;
|
||||||
|
link?: 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, ' ');
|
||||||
|
}
|
||||||
|
|
||||||
|
function stripHtml(html: string): string {
|
||||||
|
return decodeHtml(html.replace(/<[^>]+>/g, ' '))
|
||||||
|
.replace(/\s+/g, ' ')
|
||||||
|
.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
const JUNK_TITLE_WORDS = [
|
||||||
|
'cookie',
|
||||||
|
'cookies',
|
||||||
|
'aviso legal',
|
||||||
|
'politica',
|
||||||
|
'privacidad',
|
||||||
|
'contacto',
|
||||||
|
'menu',
|
||||||
|
'navegacion',
|
||||||
|
'cursos',
|
||||||
|
'centros',
|
||||||
|
'legal',
|
||||||
|
'terminos',
|
||||||
|
'newsletter',
|
||||||
|
];
|
||||||
|
|
||||||
|
function isJunkTitle(title: string): boolean {
|
||||||
|
const lower = title.toLowerCase().trim();
|
||||||
|
if (lower.length < 5) return true;
|
||||||
|
return JUNK_TITLE_WORDS.some((w) => lower === w || lower.startsWith(`${w} `) || lower.endsWith(` ${w}`));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 1. Estrategia WordPress REST API
|
||||||
|
*/
|
||||||
|
async function tryWpRestApi(origin: string, pathname: string): Promise<ExtractedBlogPost[] | null> {
|
||||||
|
const cleanPath = pathname.replace(/\/$/, '');
|
||||||
|
const candidateUrls = [
|
||||||
|
`${origin}/wp-json/wp/v2/posts?per_page=12&_embed=1`,
|
||||||
|
cleanPath ? `${origin}${cleanPath}/wp-json/wp/v2/posts?per_page=12&_embed=1` : null,
|
||||||
|
].filter(Boolean) as string[];
|
||||||
|
|
||||||
|
for (const apiUrl of candidateUrls) {
|
||||||
|
try {
|
||||||
|
const res = await fetch(apiUrl, {
|
||||||
|
headers: { 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) 4VenturesBot/1.0' },
|
||||||
|
signal: AbortSignal.timeout(5000),
|
||||||
|
});
|
||||||
|
if (!res.ok) continue;
|
||||||
|
const contentType = res.headers.get('content-type') || '';
|
||||||
|
if (!contentType.includes('json')) continue;
|
||||||
|
|
||||||
|
const items = await res.json();
|
||||||
|
if (!Array.isArray(items) || items.length === 0) continue;
|
||||||
|
|
||||||
|
const posts: ExtractedBlogPost[] = [];
|
||||||
|
for (const item of items) {
|
||||||
|
const rawTitle = item.title?.rendered || '';
|
||||||
|
const title = stripHtml(rawTitle);
|
||||||
|
if (!title || isJunkTitle(title)) continue;
|
||||||
|
|
||||||
|
let category: string | undefined;
|
||||||
|
try {
|
||||||
|
const terms = item._embedded?.['wp:term']?.[0];
|
||||||
|
if (Array.isArray(terms) && terms[0]?.name) {
|
||||||
|
category = terms[0].name;
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
|
||||||
|
const rawExcerpt = item.excerpt?.rendered || item.content?.rendered || '';
|
||||||
|
const excerpt = stripHtml(rawExcerpt).slice(0, 300);
|
||||||
|
|
||||||
|
posts.push({
|
||||||
|
title,
|
||||||
|
link: item.link || '',
|
||||||
|
date: item.date ? item.date.slice(0, 10) : undefined,
|
||||||
|
category,
|
||||||
|
excerpt,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (posts.length >= 2) {
|
||||||
|
return posts;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Intentar siguiente
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 2. Estrategia RSS / Feed
|
||||||
|
*/
|
||||||
|
async function tryRssFeed(feedUrl: string): Promise<ExtractedBlogPost[] | null> {
|
||||||
|
try {
|
||||||
|
const res = await fetch(feedUrl, {
|
||||||
|
headers: { 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) 4VenturesBot/1.0' },
|
||||||
|
signal: AbortSignal.timeout(5000),
|
||||||
|
});
|
||||||
|
if (!res.ok) return null;
|
||||||
|
const xml = await res.text();
|
||||||
|
if (!xml.includes('<rss') && !xml.includes('<feed') && !xml.includes('<channel')) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const itemMatches = xml.match(/<(?:item|entry)[\s\S]*?<\/(?:item|entry)>/gi) || [];
|
||||||
|
if (!itemMatches.length) return null;
|
||||||
|
|
||||||
|
const posts: ExtractedBlogPost[] = [];
|
||||||
|
for (const itemXml of itemMatches.slice(0, 12)) {
|
||||||
|
const titleMatch = itemXml.match(/<title[^>]*>(?:<!\[CDATA\[([\s\S]*?)\]\]>|([\s\S]*?))<\/title>/i);
|
||||||
|
const titleRaw = titleMatch ? titleMatch[1] || titleMatch[2] || '' : '';
|
||||||
|
const title = stripHtml(titleRaw);
|
||||||
|
if (!title || isJunkTitle(title)) continue;
|
||||||
|
|
||||||
|
const linkMatch =
|
||||||
|
itemXml.match(/<link[^>]*href=["']([^"']+)["']/i) ||
|
||||||
|
itemXml.match(/<link[^>]*>(?:<!\[CDATA\[([\s\S]*?)\]\]>|([\s\S]*?))<\/link>/i);
|
||||||
|
const link = linkMatch ? linkMatch[1] || linkMatch[2] || '' : '';
|
||||||
|
|
||||||
|
const descMatch = itemXml.match(/<(?:description|summary|content:encoded)[^>]*>(?:<!\[CDATA\[([\s\S]*?)\]\]>|([\s\S]*?))<\/(?:description|summary|content:encoded)>/i);
|
||||||
|
const excerptRaw = descMatch ? descMatch[1] || descMatch[2] || '' : '';
|
||||||
|
const excerpt = stripHtml(excerptRaw).slice(0, 300);
|
||||||
|
|
||||||
|
const catMatch = itemXml.match(/<category[^>]*>(?:<!\[CDATA\[([\s\S]*?)\]\]>|([\s\S]*?))<\/category>/i);
|
||||||
|
const category = catMatch ? stripHtml(catMatch[1] || catMatch[2] || '') : undefined;
|
||||||
|
|
||||||
|
const dateMatch = itemXml.match(/<(?:pubDate|published|updated)[^>]*>([\s\S]*?)<\/(?:pubDate|published|updated)>/i);
|
||||||
|
const date = dateMatch ? dateMatch[1].trim() : undefined;
|
||||||
|
|
||||||
|
posts.push({
|
||||||
|
title,
|
||||||
|
link,
|
||||||
|
excerpt,
|
||||||
|
category,
|
||||||
|
date,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return posts.length >= 2 ? posts : null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 3. Estrategia HTML Scraping (compatible con Astro, Next.js, Webflow, Ghost, etc.)
|
||||||
|
*/
|
||||||
|
function parseHtmlArticles(html: string, origin: string): ExtractedBlogPost[] {
|
||||||
|
const posts: ExtractedBlogPost[] = [];
|
||||||
|
|
||||||
|
// Buscar elementos <article>
|
||||||
|
const articleMatches = html.match(/<article[\s\S]*?<\/article>/gi) || [];
|
||||||
|
|
||||||
|
if (articleMatches.length >= 2) {
|
||||||
|
for (const art of articleMatches.slice(0, 15)) {
|
||||||
|
// Título en h2, h3 o h4
|
||||||
|
const hMatch = art.match(/<h[234][^>]*>([\s\S]*?)<\/h[234]>/i);
|
||||||
|
if (!hMatch) continue;
|
||||||
|
const title = stripHtml(hMatch[1]);
|
||||||
|
if (!title || isJunkTitle(title)) continue;
|
||||||
|
|
||||||
|
// Link
|
||||||
|
const linkMatch = art.match(/href=["']([^"']+)["']/i);
|
||||||
|
let link = linkMatch ? linkMatch[1] : '';
|
||||||
|
if (link && link.startsWith('/')) {
|
||||||
|
link = `${origin}${link}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fecha
|
||||||
|
const dateMatch =
|
||||||
|
art.match(/<time[^>]*>([\s\S]*?)<\/time>/i) ||
|
||||||
|
art.match(/(\d{1,2}\s+(?:de\s+)?[a-záéíóú]+\s+(?:de\s+)?\d{4})/i);
|
||||||
|
const date = dateMatch ? stripHtml(dateMatch[1]) : undefined;
|
||||||
|
|
||||||
|
// Categoría
|
||||||
|
const catMatch = art.match(/<(?:span|div|a|p)[^>]*class=["'][^"']*(?:categoria|tag|category|badge)[^"']*["'][^>]*>([\s\S]*?)<\/(?:span|div|a|p)>/i);
|
||||||
|
let category = catMatch ? stripHtml(catMatch[1]) : undefined;
|
||||||
|
|
||||||
|
// Si no hay badge específico, buscar etiqueta inicial
|
||||||
|
if (!category) {
|
||||||
|
const textSample = stripHtml(art);
|
||||||
|
const firstWordMatch = textSample.match(/^([A-ZÁÉÍÓÚ][a-záéíóú]+)\b/);
|
||||||
|
if (firstWordMatch && ['Italiano', 'Alemán', 'Inglés', 'Francés', 'General', 'Tips', 'Guía', 'Novedades', 'Opinión'].includes(firstWordMatch[1])) {
|
||||||
|
category = firstWordMatch[1];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Párrafos para extracto
|
||||||
|
const pMatches = art.match(/<p[^>]*>([\s\S]*?)<\/p>/gi) || [];
|
||||||
|
const paragraphs = pMatches
|
||||||
|
.map((p) => stripHtml(p))
|
||||||
|
.filter((p) => p.length > 25 && !p.toLowerCase().includes('copyright') && !p.toLowerCase().includes('todos los derechos'));
|
||||||
|
const excerpt = (paragraphs[0] || '').slice(0, 300);
|
||||||
|
|
||||||
|
posts.push({
|
||||||
|
title,
|
||||||
|
link,
|
||||||
|
date,
|
||||||
|
category,
|
||||||
|
excerpt,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (posts.length >= 2) return posts;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback: buscar enlaces con h2/h3 o cards con href a /blog/
|
||||||
|
const cardRegex = /<a[^>]+href=["']([^"']*(?:\/blog\/|[a-z0-9-]{15,})[^"']*)["'][^>]*>([\s\S]*?)<\/a>/gi;
|
||||||
|
let match: RegExpExecArray | null;
|
||||||
|
while ((match = cardRegex.exec(html)) !== null) {
|
||||||
|
const rawInner = match[2];
|
||||||
|
const hMatch = rawInner.match(/<h[234][^>]*>([\s\S]*?)<\/h[234]>/i);
|
||||||
|
if (!hMatch) continue;
|
||||||
|
const title = stripHtml(hMatch[1]);
|
||||||
|
if (!title || isJunkTitle(title)) continue;
|
||||||
|
|
||||||
|
let link = match[1];
|
||||||
|
if (link.startsWith('/')) link = `${origin}${link}`;
|
||||||
|
|
||||||
|
const excerpt = stripHtml(rawInner.replace(hMatch[0], '')).slice(0, 250);
|
||||||
|
|
||||||
|
posts.push({
|
||||||
|
title,
|
||||||
|
link,
|
||||||
|
excerpt,
|
||||||
|
});
|
||||||
|
if (posts.length >= 12) break;
|
||||||
|
}
|
||||||
|
|
||||||
|
return posts;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Orquestador principal de ingesta del blog
|
||||||
|
*/
|
||||||
|
export async function fetchBlogPosts(rawUrl: string): Promise<ExtractedBlogPost[]> {
|
||||||
|
let cleanUrl = rawUrl.trim();
|
||||||
|
if (!cleanUrl.startsWith('http://') && !cleanUrl.startsWith('https://')) {
|
||||||
|
cleanUrl = `https://${cleanUrl}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsedUrl = new URL(cleanUrl);
|
||||||
|
const origin = parsedUrl.origin;
|
||||||
|
const pathname = parsedUrl.pathname;
|
||||||
|
|
||||||
|
// 1. Probar WP REST API
|
||||||
|
const wpPosts = await tryWpRestApi(origin, pathname);
|
||||||
|
if (wpPosts && wpPosts.length >= 2) {
|
||||||
|
return wpPosts;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Probar URLs típicas de Feed RSS
|
||||||
|
const commonFeeds = [
|
||||||
|
`${cleanUrl.replace(/\/$/, '')}/feed`,
|
||||||
|
`${origin}/feed`,
|
||||||
|
`${cleanUrl.replace(/\/$/, '')}/rss`,
|
||||||
|
`${origin}/blog/feed`,
|
||||||
|
];
|
||||||
|
for (const feedUrl of commonFeeds) {
|
||||||
|
const feedPosts = await tryRssFeed(feedUrl);
|
||||||
|
if (feedPosts && feedPosts.length >= 2) {
|
||||||
|
return feedPosts;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Probar descarga de HTML directo
|
||||||
|
const res = await fetch(cleanUrl, {
|
||||||
|
headers: {
|
||||||
|
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36 4VenturesBot/1.0',
|
||||||
|
Accept: 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
|
||||||
|
},
|
||||||
|
signal: AbortSignal.timeout(8000),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(`No se pudo acceder a ${cleanUrl} (HTTP ${res.status})`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const html = await res.text();
|
||||||
|
|
||||||
|
// Si en el <head> hay un <link rel="alternate" type="application/rss+xml" href="...">
|
||||||
|
const rssLinkMatch = html.match(/<link[^>]+type=["']application\/rss\+xml["'][^>]+href=["']([^"']+)["']/i);
|
||||||
|
if (rssLinkMatch?.[1]) {
|
||||||
|
let rssHref = rssLinkMatch[1];
|
||||||
|
if (rssHref.startsWith('/')) rssHref = `${origin}${rssHref}`;
|
||||||
|
const feedPosts = await tryRssFeed(rssHref);
|
||||||
|
if (feedPosts && feedPosts.length >= 2) {
|
||||||
|
return feedPosts;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parsear artículos HTML
|
||||||
|
const htmlPosts = parseHtmlArticles(html, origin);
|
||||||
|
if (htmlPosts.length >= 1) {
|
||||||
|
return htmlPosts;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error(
|
||||||
|
`No se pudieron detectar artículos en ${cleanUrl}. Asegúrate de que la URL apunta al listado del blog o a un feed RSS válido.`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Análisis estratégico de los posts del blog utilizando Claude (Anthropic)
|
||||||
|
*/
|
||||||
|
export async function analyzeBlogWithClaude(
|
||||||
|
posts: ExtractedBlogPost[],
|
||||||
|
brandName: string,
|
||||||
|
extraContext?: string
|
||||||
|
): Promise<BlogAnalysisResult> {
|
||||||
|
const apiKey =
|
||||||
|
process.env.ANTHROPIC_API_KEY?.trim() ||
|
||||||
|
'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';
|
||||||
|
|
||||||
|
const postsSummary = posts
|
||||||
|
.map((p, idx) => {
|
||||||
|
const parts = [`${idx + 1}. "${p.title}"`];
|
||||||
|
if (p.category) parts.push(`[Cat: ${p.category}]`);
|
||||||
|
if (p.date) parts.push(`[Fecha: ${p.date}]`);
|
||||||
|
if (p.excerpt) parts.push(`\n Resumen: ${p.excerpt.slice(0, 160)}...`);
|
||||||
|
return parts.join(' ');
|
||||||
|
})
|
||||||
|
.join('\n');
|
||||||
|
|
||||||
|
const prompt = `Eres el Agente Estratega de Contenidos y SEO de 4Ventures (Agency OS).
|
||||||
|
Debes analizar los siguientes ${posts.length} artículos publicados recientemente en el blog corporativo de "${brandName}".
|
||||||
|
|
||||||
|
${extraContext ? `Contexto adicional de la marca:\n${extraContext}\n\n` : ''}Artículos extraídos del blog:
|
||||||
|
${postsSummary}
|
||||||
|
|
||||||
|
Tu objetivo es extraer el ADN editorial y estratégico del blog para alimentar el Brandbook de la marca.
|
||||||
|
Genera ÚNICAMENTE un objeto JSON válido (sin explicaciones previas ni posteriores, sin bloques markdown de diálogo) con esta estructura exacta:
|
||||||
|
|
||||||
|
{
|
||||||
|
"content_clusters": [
|
||||||
|
"Cluster temático principal 1",
|
||||||
|
"Cluster temático principal 2",
|
||||||
|
"Cluster temático principal 3",
|
||||||
|
"Cluster temático principal 4"
|
||||||
|
],
|
||||||
|
"blog_seo_keywords": [
|
||||||
|
"keyword seo 1",
|
||||||
|
"keyword seo 2",
|
||||||
|
"keyword seo 3",
|
||||||
|
"keyword seo 4",
|
||||||
|
"keyword seo 5",
|
||||||
|
"keyword seo 6",
|
||||||
|
"keyword seo 7",
|
||||||
|
"keyword seo 8",
|
||||||
|
"keyword seo 9",
|
||||||
|
"keyword seo 10"
|
||||||
|
],
|
||||||
|
"blog_themes": [
|
||||||
|
"Línea o formato recurrente 1",
|
||||||
|
"Línea o formato recurrente 2",
|
||||||
|
"Línea o formato recurrente 3",
|
||||||
|
"Línea o formato recurrente 4",
|
||||||
|
"Línea o formato recurrente 5"
|
||||||
|
],
|
||||||
|
"blog_cta": [
|
||||||
|
"Llamada a la acción recomendada 1",
|
||||||
|
"Llamada a la acción recomendada 2",
|
||||||
|
"Llamada a la acción recomendada 3",
|
||||||
|
"Llamada a la acción recomendada 4"
|
||||||
|
],
|
||||||
|
"blog_cadence": "Cadencia estimada según fechas (ej. '1-2 artículos/semana')",
|
||||||
|
"editorial_summary": "Resumen conciso de 2-3 frases sobre la voz, propósito y público al que se dirige el blog."
|
||||||
|
}
|
||||||
|
|
||||||
|
Instrucciones:
|
||||||
|
1. "content_clusters": Agrupa los temas en 3 a 5 grandes pilares de contenido SEO de la marca.
|
||||||
|
2. "blog_seo_keywords": Lista entre 8 y 15 palabras clave transaccionales e informativas que los artículos trabajan de forma natural.
|
||||||
|
3. "blog_themes": Identifica 4 a 6 tipos de contenido que se repiten (ej. guías paso a paso, comparativas, recursos de ocio, casos de uso).
|
||||||
|
4. "blog_cta": Propón 3 a 5 CTAs lógicos que conecten el blog con la oferta comercial de ${brandName}.
|
||||||
|
5. "blog_cadence": Estima el ritmo de publicación.
|
||||||
|
6. "editorial_summary": Define el estilo y enfoque del blog en español fluido.`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
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: 2000,
|
||||||
|
messages: [{ role: 'user', content: prompt }],
|
||||||
|
}),
|
||||||
|
signal: AbortSignal.timeout(25000),
|
||||||
|
});
|
||||||
|
|
||||||
|
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 {
|
||||||
|
content_clusters: Array.isArray(parsed.content_clusters) ? parsed.content_clusters : [],
|
||||||
|
blog_seo_keywords: Array.isArray(parsed.blog_seo_keywords) ? parsed.blog_seo_keywords : [],
|
||||||
|
blog_themes: Array.isArray(parsed.blog_themes) ? parsed.blog_themes : [],
|
||||||
|
blog_cta: Array.isArray(parsed.blog_cta) ? parsed.blog_cta : [],
|
||||||
|
blog_cadence: typeof parsed.blog_cadence === 'string' ? parsed.blog_cadence : '1-2 posts/mes',
|
||||||
|
editorial_summary: typeof parsed.editorial_summary === 'string' ? parsed.editorial_summary : '',
|
||||||
|
posts_analyzed: posts.length,
|
||||||
|
posts_sample: posts.slice(0, 8).map((p) => ({
|
||||||
|
title: p.title,
|
||||||
|
category: p.category,
|
||||||
|
date: p.date,
|
||||||
|
link: p.link,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
} catch (err: any) {
|
||||||
|
// Si falla la llamada a la API por modelo u otra causa, proveemos extracción heurística
|
||||||
|
console.warn('[blog-analyzer] Fallback heurístico tras error en Claude:', err?.message);
|
||||||
|
|
||||||
|
const categories = Array.from(new Set(posts.map((p) => p.category).filter(Boolean) as string[]));
|
||||||
|
const allTitles = posts.map((p) => p.title);
|
||||||
|
|
||||||
|
return {
|
||||||
|
content_clusters: categories.length ? categories : ['Artículos y Guías Generales', 'Novedades y Consejos'],
|
||||||
|
blog_seo_keywords: allTitles.slice(0, 8),
|
||||||
|
blog_themes: [
|
||||||
|
'Guías prácticas y consejos',
|
||||||
|
'Preguntas frecuentes y comparativas',
|
||||||
|
'Recursos recomendados para alumnos/clientes',
|
||||||
|
],
|
||||||
|
blog_cta: [
|
||||||
|
`Descubre más en ${brandName}`,
|
||||||
|
'Contacta con nuestro equipo',
|
||||||
|
'Prueba una demostración gratuita',
|
||||||
|
],
|
||||||
|
blog_cadence: '1-2 posts/mes',
|
||||||
|
editorial_summary: `Blog corporativo de ${brandName} enfocado en resolver dudas frecuentes y aportar valor antes de la contratación.`,
|
||||||
|
posts_analyzed: posts.length,
|
||||||
|
posts_sample: posts.slice(0, 8).map((p) => ({
|
||||||
|
title: p.title,
|
||||||
|
category: p.category,
|
||||||
|
date: p.date,
|
||||||
|
link: p.link,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user