feat(brandbook): anadir ingesta y analisis automatico de blog con IA en Zona de Marca

This commit is contained in:
Marcos Fargas
2026-10-15 00:18:22 +02:00
parent 8b101c094c
commit 85e9f4c184
3 changed files with 982 additions and 45 deletions

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

View File

@@ -13,7 +13,13 @@ import {
ImageIcon,
Type,
Palette,
Globe,
ExternalLink,
X,
Check,
Rss,
} from 'lucide-react';
import type { BlogAnalysisResult } from '@/lib/blog-analyzer';
import { PageHeader, Surface, SectionTitle, SegmentedControl } from '@/components/ui';
import {
ASSET_KIND_LABELS,
@@ -123,6 +129,10 @@ export default function BrandZone({
const [exists, setExists] = useState(false);
const [kitKind, setKitKind] = useState<BrandAssetKind>('logo');
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 () => {
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) {
if (!file && !paste.trim()) return;
setUploading(true);
@@ -2073,8 +2140,240 @@ export default function BrandZone({
)}
{section === 'blog' && (
<div className="grid gap-3 sm:grid-cols-2">
<Field label="Temas blog">
<div className="space-y-4">
{/* Tarjeta de Ingesta y Análisis Automático con IA */}
<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">
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-3">
<div className="space-y-1">
<div className="flex items-center gap-2">
<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>
<h4 className="text-sm font-bold text-gray-900">
Ingesta y Análisis Automático del Blog con IA
</h4>
<span className="rounded-full bg-brand-100 px-2.5 py-0.5 text-[10px] font-semibold text-brand-700">
Claude + Crawler
</span>
</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.
</p>
</div>
</div>
<div className="flex flex-col sm:flex-row items-stretch sm:items-end gap-2.5 pt-1">
<div className="flex-1 space-y-1">
<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" />
URL del Blog Corporativo (o Feed RSS)
</span>
{book.channel_handles?.blog && (
<a
href={book.channel_handles.blog}
target="_blank"
rel="noopener noreferrer"
className="text-[11px] font-normal text-brand-600 hover:text-brand-800 flex items-center gap-1"
>
Visitar blog
<ExternalLink className="w-3 h-3" />
</a>
)}
</label>
<input
className={inputCls}
value={book.channel_handles?.blog || ''}
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)}
@@ -2083,7 +2382,10 @@ export default function BrandZone({
}
/>
</Field>
<Field label="Keywords SEO">
<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)}
@@ -2092,7 +2394,10 @@ export default function BrandZone({
}
/>
</Field>
<Field label="Clusters de contenido">
<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)}
@@ -2101,7 +2406,10 @@ export default function BrandZone({
}
/>
</Field>
<Field label="CTAs del blog">
<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)}
@@ -2110,7 +2418,10 @@ export default function BrandZone({
}
/>
</Field>
<Field label="Cadencia">
<Field
label="Cadencia"
hint="Frecuencia habitual de publicación en el blog"
>
<input
className={inputCls}
value={book.blog_cadence}
@@ -2119,6 +2430,7 @@ export default function BrandZone({
/>
</Field>
</div>
</div>
)}
{section === 'compliance' && (

501
src/lib/blog-analyzer.ts Normal file
View 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(/&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, ' ');
}
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,
})),
};
}
}