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:
@@ -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
404
src/lib/seo-audit.ts
Normal 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(),
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user