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

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