feat: auditoría SEO real con histórico y avisos

Crawl técnico (HTTPS/robots/sitemap/on-page) + tabla seo_audits; UI con nota, checks y alertas.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
4Ventures
2026-09-24 16:55:58 +02:00
parent d896041bed
commit 06024cf473
6 changed files with 1008 additions and 67 deletions

View File

@@ -65,6 +65,28 @@ export type IntegrationLite = {
external_account_id?: string | null;
last_sync_at?: string | null;
error_message?: string | null;
meta?: { property_id?: string; site_url?: string };
};
export type SeoAuditCheckRow = {
id: string;
category: string;
title: string;
status: 'ok' | 'info' | 'warn' | 'critical';
detail: string;
};
export type SeoAuditRow = {
id?: string;
tenant_id?: string;
site_url?: string;
score?: number;
grade?: string;
warning_count?: number;
critical_count?: number;
checks?: SeoAuditCheckRow[];
summary?: string | null;
created_at?: string;
};
function n(v: unknown): number {

404
src/lib/seo-audit.ts Normal file
View File

@@ -0,0 +1,404 @@
/** Technical SEO site audit — fetch homepage / robots / sitemap + connection checks. */
export type SeoAuditSeverity = 'ok' | 'info' | 'warn' | 'critical';
export type SeoAuditCheck = {
id: string;
category: string;
title: string;
status: SeoAuditSeverity;
detail: string;
};
export type SeoAuditResult = {
siteUrl: string;
score: number;
grade: string;
warningCount: number;
criticalCount: number;
checks: SeoAuditCheck[];
summary: string;
createdAt: string;
};
function gradeFromScore(score: number): string {
if (score >= 90) return 'A';
if (score >= 75) return 'B';
if (score >= 60) return 'C';
if (score >= 40) return 'D';
return 'F';
}
function siteBaseFromGsc(siteUrl: string): string | null {
const s = (siteUrl || '').trim();
if (!s) return null;
if (s.startsWith('sc-domain:')) {
const host = s.replace(/^sc-domain:/i, '').replace(/\/$/, '');
return host ? `https://${host}` : null;
}
try {
const u = new URL(s);
return `${u.protocol}//${u.host}`;
} catch {
return null;
}
}
async function fetchText(
url: string,
opts?: { timeoutMs?: number; method?: string }
): Promise<{
ok: boolean;
status: number;
text: string;
finalUrl: string;
headers: Headers;
}> {
const ctrl = new AbortController();
const t = setTimeout(() => ctrl.abort(), opts?.timeoutMs ?? 12000);
try {
const r = await fetch(url, {
method: opts?.method || 'GET',
redirect: 'follow',
signal: ctrl.signal,
headers: {
'User-Agent':
'4Ventures-SEO-Audit/1.0 (+https://app.4ventures.es; marketing@4ventures.es)',
Accept: 'text/html,application/xhtml+xml,text/plain,*/*',
},
cache: 'no-store',
});
const text = await r.text();
return {
ok: r.ok,
status: r.status,
text: text.slice(0, 400_000),
finalUrl: r.url || url,
headers: r.headers,
};
} catch (e) {
return {
ok: false,
status: 0,
text: e instanceof Error ? e.message : 'fetch error',
finalUrl: url,
headers: new Headers(),
};
} finally {
clearTimeout(t);
}
}
function metaContent(html: string, name: string): string | null {
const re = new RegExp(
`<meta[^>]+(?:name|property)=["']${name}["'][^>]*content=["']([^"']*)["']`,
'i'
);
const re2 = new RegExp(
`<meta[^>]+content=["']([^"']*)["'][^>]*(?:name|property)=["']${name}["']`,
'i'
);
return html.match(re)?.[1] || html.match(re2)?.[1] || null;
}
function firstTag(html: string, tag: string): string | null {
const m = html.match(new RegExp(`<${tag}[^>]*>([\\s\\S]*?)</${tag}>`, 'i'));
return m ? m[1].replace(/<[^>]+>/g, '').trim() : null;
}
function hasCanonical(html: string): string | null {
const m = html.match(
/<link[^>]+rel=["']canonical["'][^>]*href=["']([^"']+)["']/i
);
const m2 = html.match(
/<link[^>]+href=["']([^"']+)["'][^>]*rel=["']canonical["']/i
);
return m?.[1] || m2?.[1] || null;
}
function scoreChecks(checks: SeoAuditCheck[]): {
score: number;
warningCount: number;
criticalCount: number;
} {
let score = 100;
let warningCount = 0;
let criticalCount = 0;
for (const c of checks) {
if (c.status === 'warn') {
warningCount += 1;
score -= 8;
} else if (c.status === 'critical') {
criticalCount += 1;
score -= 18;
} else if (c.status === 'info') {
score -= 2;
}
}
return {
score: Math.max(0, Math.min(100, score)),
warningCount,
criticalCount,
};
}
export async function runSeoSiteAudit(opts: {
gscSiteUrl?: string | null;
ga4Connected: boolean;
gscConnected: boolean;
samplePages?: string[];
}): Promise<SeoAuditResult> {
const checks: SeoAuditCheck[] = [];
const base = siteBaseFromGsc(opts.gscSiteUrl || '') || null;
// Measurement
checks.push({
id: 'gsc',
category: 'Medición',
title: 'Search Console conectado',
status: opts.gscConnected ? 'ok' : 'critical',
detail: opts.gscConnected
? opts.gscSiteUrl || 'Conectado'
: 'Sin propiedad GSC en la ficha — no hay datos de búsqueda.',
});
checks.push({
id: 'ga4',
category: 'Medición',
title: 'GA4 conectado',
status: opts.ga4Connected ? 'ok' : 'critical',
detail: opts.ga4Connected
? 'Property GA4 enlazada'
: 'Sin GA4 — no se mide tráfico/conversiones orgánicas.',
});
if (!base) {
checks.push({
id: 'site',
category: 'Acceso',
title: 'URL del sitio',
status: 'critical',
detail:
'No se pudo resolver la URL desde GSC (falta sc-domain: o https://…).',
});
const { score, warningCount, criticalCount } = scoreChecks(checks);
return {
siteUrl: opts.gscSiteUrl || '—',
score,
grade: gradeFromScore(score),
warningCount,
criticalCount,
checks,
summary: 'Auditoría incompleta: sin URL de sitio.',
createdAt: new Date().toISOString(),
};
}
// HTTPS / homepage
const home = await fetchText(base);
const isHttps = home.finalUrl.startsWith('https://');
checks.push({
id: 'https',
category: 'Acceso',
title: 'HTTPS',
status: isHttps ? 'ok' : 'critical',
detail: isHttps
? `Resuelve a ${home.finalUrl}`
: `La home no fuerza HTTPS (${home.finalUrl || base})`,
});
checks.push({
id: 'home_status',
category: 'Acceso',
title: 'Home responde',
status: home.ok ? 'ok' : 'critical',
detail: home.ok
? `HTTP ${home.status}`
: `No se pudo cargar la home (${home.status || 'error'}: ${home.text.slice(0, 80)})`,
});
if (home.ok) {
const html = home.text;
const title = firstTag(html, 'title');
const desc = metaContent(html, 'description');
const h1 = firstTag(html, 'h1');
const viewport = metaContent(html, 'viewport');
const canonical = hasCanonical(html);
const robotsMeta = metaContent(html, 'robots');
checks.push({
id: 'title',
category: 'On-page',
title: 'Title',
status: title && title.length >= 10 && title.length <= 70 ? 'ok' : title ? 'warn' : 'critical',
detail: title
? `"${title.slice(0, 90)}" (${title.length} chars)`
: 'Sin <title>',
});
checks.push({
id: 'meta_desc',
category: 'On-page',
title: 'Meta description',
status: desc && desc.length >= 50 && desc.length <= 160 ? 'ok' : desc ? 'warn' : 'warn',
detail: desc
? `${desc.length} chars`
: 'Sin meta description (Google puede generar un snippet peor)',
});
checks.push({
id: 'h1',
category: 'On-page',
title: 'H1',
status: h1 ? 'ok' : 'warn',
detail: h1 ? h1.slice(0, 100) : 'Sin H1 visible en el HTML inicial',
});
checks.push({
id: 'viewport',
category: 'On-page',
title: 'Viewport móvil',
status: viewport ? 'ok' : 'warn',
detail: viewport || 'Falta meta viewport (móvil)',
});
checks.push({
id: 'canonical',
category: 'On-page',
title: 'Canonical',
status: canonical ? 'ok' : 'info',
detail: canonical || 'Sin link rel=canonical en home',
});
if (robotsMeta && /noindex/i.test(robotsMeta)) {
checks.push({
id: 'noindex',
category: 'Indexación',
title: 'Meta robots noindex',
status: 'critical',
detail: `Home con robots="${robotsMeta}" — puede no indexarse.`,
});
} else {
checks.push({
id: 'noindex',
category: 'Indexación',
title: 'Home indexable',
status: 'ok',
detail: robotsMeta ? `robots="${robotsMeta}"` : 'Sin noindex en home',
});
}
}
// robots.txt
const robots = await fetchText(`${base}/robots.txt`);
if (!robots.ok) {
checks.push({
id: 'robots',
category: 'Indexación',
title: 'robots.txt',
status: 'warn',
detail: `No accesible (${robots.status}). Google usará defaults.`,
});
} else {
const blocksAll = /User-agent:\s*\*[\s\S]*?Disallow:\s*\/\s*$/im.test(
robots.text
);
const hasSitemap = /sitemap:\s*\S+/i.test(robots.text);
checks.push({
id: 'robots',
category: 'Indexación',
title: 'robots.txt',
status: blocksAll ? 'critical' : 'ok',
detail: blocksAll
? 'Disallow: / para * — bloquea el rastreo completo'
: `OK${hasSitemap ? ' · declara Sitemap' : ' · sin Sitemap en robots'}`,
});
if (!hasSitemap) {
checks.push({
id: 'robots_sitemap',
category: 'Indexación',
title: 'Sitemap declarado en robots',
status: 'info',
detail: 'Conviene añadir "Sitemap: https://…/sitemap.xml"',
});
}
}
// sitemap
const smCandidates = [
`${base}/sitemap.xml`,
`${base}/sitemap_index.xml`,
`${base}/wp-sitemap.xml`,
];
let sitemapOk = false;
let sitemapDetail = '';
for (const u of smCandidates) {
const sm = await fetchText(u);
if (sm.ok && /<urlset|<sitemapindex/i.test(sm.text)) {
const urls = (sm.text.match(/<loc>/gi) || []).length;
sitemapOk = true;
sitemapDetail = `${u} · ~${urls} <loc>`;
break;
}
}
checks.push({
id: 'sitemap',
category: 'Indexación',
title: 'Sitemap XML',
status: sitemapOk ? 'ok' : 'warn',
detail: sitemapOk
? sitemapDetail
: 'No se encontró sitemap.xml / wp-sitemap.xml accesible',
});
// Sample top pages (lightweight)
const samples = (opts.samplePages || [])
.filter((u) => /^https?:\/\//i.test(u))
.slice(0, 3);
for (const pageUrl of samples) {
const page = await fetchText(pageUrl);
const id = `page_${checks.length}`;
if (!page.ok) {
checks.push({
id,
category: 'Páginas top GSC',
title: pageUrl.replace(/^https?:\/\//, '').slice(0, 60),
status: 'warn',
detail: `HTTP ${page.status || 'error'}`,
});
continue;
}
const title = firstTag(page.text, 'title');
const h1 = firstTag(page.text, 'h1');
const issues: string[] = [];
if (!title) issues.push('sin title');
if (!h1) issues.push('sin H1');
if (/noindex/i.test(metaContent(page.text, 'robots') || '')) {
issues.push('noindex');
}
checks.push({
id,
category: 'Páginas top GSC',
title: pageUrl.replace(/^https?:\/\//, '').slice(0, 60),
status: issues.includes('noindex')
? 'critical'
: issues.length
? 'warn'
: 'ok',
detail: issues.length ? issues.join(', ') : `OK · "${(title || '').slice(0, 50)}"`,
});
}
const { score, warningCount, criticalCount } = scoreChecks(checks);
const grade = gradeFromScore(score);
const summary =
criticalCount > 0
? `${criticalCount} crítico(s) · ${warningCount} aviso(s) · nota ${score}/100 (${grade})`
: warningCount > 0
? `${warningCount} aviso(s) · nota ${score}/100 (${grade})`
: `Sin críticos · nota ${score}/100 (${grade})`;
return {
siteUrl: base,
score,
grade,
warningCount,
criticalCount,
checks,
summary,
createdAt: new Date().toISOString(),
};
}