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

@@ -7,7 +7,7 @@ import {
userRole,
} from '@/lib/agency';
import { resolveActiveTenant } from '@/lib/tenant-context';
import type { GeoSnapshot, OrganicDailyRow } from '@/lib/organic';
import type { GeoSnapshot, OrganicDailyRow, SeoAuditRow } from '@/lib/organic';
import {
fetchGa4TopLandings,
fetchGscTopRows,
@@ -16,6 +16,7 @@ import {
} from '@/lib/google-organic';
import { getAgencyOAuthStatus } from '@/lib/google-drive';
import { isoYesterday } from '@/lib/ad-tracker-range';
import { runSeoSiteAudit } from '@/lib/seo-audit';
export const dynamic = 'force-dynamic';
export const maxDuration = 120;
@@ -136,6 +137,7 @@ export async function GET(req: NextRequest) {
portfolioWide: true,
organic: [] as OrganicDailyRow[],
geo: [] as GeoSnapshot[],
seoAudits: [] as SeoAuditRow[],
integrations: [],
warning: 'Selecciona un cliente en Contexto para ver Orgánico.',
});
@@ -170,7 +172,7 @@ export async function GET(req: NextRequest) {
since && until
? `&day=gte.${since}&day=lte.${until}`
: '';
const [oRes, gRes, iRes, tRes] = await Promise.all([
const [oRes, gRes, iRes, tRes, aRes] = await Promise.all([
fetch(
`${base}/rest/v1/organic_daily?tenant_id=eq.${encodeURIComponent(tid)}&select=*&order=day.asc${dayFilter}`,
{ headers, cache: 'no-store' }
@@ -187,13 +189,18 @@ export async function GET(req: NextRequest) {
`${base}/rest/v1/tenants?id=eq.${encodeURIComponent(tid)}&select=settings`,
{ headers, cache: 'no-store' }
),
fetch(
`${base}/rest/v1/seo_audits?tenant_id=eq.${encodeURIComponent(tid)}&select=*&order=created_at.desc&limit=20`,
{ headers, cache: 'no-store' }
),
]);
const warningParts: string[] = [];
let organic: OrganicDailyRow[] = [];
let geo: GeoSnapshot[] = [];
let integrations: unknown[] = [];
let workspace = {};
let workspace: Record<string, unknown> = {};
let seoAudits: SeoAuditRow[] = [];
if (oRes.ok) organic = await oRes.json();
else {
@@ -216,6 +223,17 @@ export async function GET(req: NextRequest) {
const rows = await tRes.json();
workspace = rows[0]?.settings?.organico || {};
}
if (aRes.ok) {
seoAudits = await aRes.json();
} else {
const t = await aRes.text();
if (t.includes('seo_audits')) {
const fallback = Array.isArray(workspace.seoAudits)
? (workspace.seoAudits as SeoAuditRow[])
: [];
seoAudits = fallback;
}
}
const live = await loadLiveSeoDims(
integrations as {
@@ -232,6 +250,7 @@ export async function GET(req: NextRequest) {
tenantId: tid,
organic,
geo,
seoAudits,
integrations,
workspace,
autoSynced,
@@ -253,7 +272,7 @@ export async function GET(req: NextRequest) {
.order('day', { ascending: true });
if (since) organicQ = organicQ.gte('day', since);
if (until) organicQ = organicQ.lte('day', until);
const [o, ge, integ, ten] = await Promise.all([
const [o, ge, integ, ten, aud] = await Promise.all([
organicQ,
supabase
.from('geo_snapshots')
@@ -267,8 +286,21 @@ export async function GET(req: NextRequest) {
)
.in('provider', ['ga4', 'search_console', 'tag_manager']),
supabase.from('tenants').select('settings').eq('id', tid).maybeSingle(),
supabase
.from('seo_audits')
.select('*')
.order('created_at', { ascending: false })
.limit(20),
]);
const workspace =
(ten.data?.settings as { organico?: Record<string, unknown> } | null)
?.organico || {};
let seoAudits: SeoAuditRow[] = (aud.data as SeoAuditRow[]) || [];
if (aud.error && Array.isArray(workspace.seoAudits)) {
seoAudits = workspace.seoAudits as SeoAuditRow[];
}
const live = await loadLiveSeoDims(
(integ.data || []) as {
provider?: string;
@@ -284,8 +316,9 @@ export async function GET(req: NextRequest) {
tenantId: tid,
organic: o.data || [],
geo: ge.data || [],
seoAudits,
integrations: integ.data || [],
workspace: (ten.data?.settings as { organico?: unknown } | null)?.organico || {},
workspace,
autoSynced,
gscQueries: live.gscQueries,
gscPages: live.gscPages,
@@ -327,9 +360,147 @@ export async function POST(req: NextRequest) {
? 'geo'
: body.kind === 'workspace'
? 'workspace'
: 'organic';
: body.kind === 'seo-audit'
? 'seo-audit'
: 'organic';
const base = supabaseUrl();
if (kind === 'seo-audit') {
const iRes = await fetch(
`${base}/rest/v1/integrations?tenant_id=eq.${encodeURIComponent(ctx.tenantId)}&provider=in.(ga4,search_console)&select=provider,status,external_account_id,meta`,
{ headers, cache: 'no-store' }
);
const integrations = iRes.ok
? ((await iRes.json()) as {
provider?: string;
status?: string;
external_account_id?: string | null;
meta?: { site_url?: string; property_id?: string };
}[])
: [];
const gsc = integrations.find(
(i) => i.provider === 'search_console' && i.status === 'connected'
);
const ga4 = integrations.find(
(i) => i.provider === 'ga4' && i.status === 'connected'
);
const gscSiteUrl =
gsc?.meta?.site_url ||
(gsc?.external_account_id?.includes('http') ||
gsc?.external_account_id?.startsWith('sc-domain:')
? gsc.external_account_id
: null);
let samplePages: string[] = [];
if (gscSiteUrl) {
try {
const until = isoYesterday();
const sinceDate = new Date(`${until}T12:00:00Z`);
sinceDate.setUTCDate(sinceDate.getUTCDate() - 27);
const since = sinceDate.toISOString().slice(0, 10);
const pages = await fetchGscTopRows({
siteUrl: gscSiteUrl,
startDate: since,
endDate: until,
dimension: 'page',
});
samplePages = pages.map((p) => p.key).slice(0, 3);
} catch {
// non-fatal
}
}
const result = await runSeoSiteAudit({
gscSiteUrl,
ga4Connected: ga4?.status === 'connected',
gscConnected: gsc?.status === 'connected',
samplePages,
});
const row = {
tenant_id: ctx.tenantId,
site_url: result.siteUrl,
score: result.score,
grade: result.grade,
warning_count: result.warningCount,
critical_count: result.criticalCount,
checks: result.checks,
summary: result.summary,
};
const r = await fetch(`${base}/rest/v1/seo_audits`, {
method: 'POST',
headers: {
...headers,
'Content-Type': 'application/json',
Prefer: 'return=representation',
},
body: JSON.stringify(row),
});
if (r.ok) {
const saved = (await r.json())[0] as SeoAuditRow;
return NextResponse.json({ audit: saved });
}
// Fallback: persist last 20 audits in tenants.settings.organico.seoAudits
const msg = await r.text();
if (!msg.includes('seo_audits')) {
return NextResponse.json({ error: msg.slice(0, 300) }, { status: 500 });
}
const cur = await fetch(
`${base}/rest/v1/tenants?id=eq.${encodeURIComponent(ctx.tenantId)}&select=settings`,
{ headers, cache: 'no-store' }
);
let settings: Record<string, unknown> = {};
if (cur.ok) {
const rows = await cur.json();
settings =
rows[0]?.settings && typeof rows[0].settings === 'object'
? { ...rows[0].settings }
: {};
}
const prevOrg =
settings.organico && typeof settings.organico === 'object'
? (settings.organico as Record<string, unknown>)
: {};
const prevAudits = Array.isArray(prevOrg.seoAudits)
? (prevOrg.seoAudits as SeoAuditRow[])
: [];
const audit: SeoAuditRow = {
id: `ws-${Date.now()}`,
...row,
created_at: result.createdAt,
};
settings.organico = {
...prevOrg,
seoAudits: [audit, ...prevAudits].slice(0, 20),
};
const patch = await fetch(
`${base}/rest/v1/tenants?id=eq.${encodeURIComponent(ctx.tenantId)}`,
{
method: 'PATCH',
headers: {
...headers,
'Content-Type': 'application/json',
Prefer: 'return=representation',
},
body: JSON.stringify({ settings }),
}
);
if (!patch.ok) {
return NextResponse.json(
{ error: (await patch.text()).slice(0, 300) },
{ status: 500 }
);
}
return NextResponse.json({
audit,
warning: 'seo_audits table missing — histórico en settings (aplicar sql/seo_audits.sql)',
});
}
if (kind === 'workspace') {
// Merge organico workspace into tenants.settings
const cur = await fetch(

View File

@@ -3,6 +3,25 @@
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import Link from 'next/link';
import {
GEO_ENGINES,
ORGANICO_SECTIONS,
GeoSnapshot,
IntegrationLite,
OrganicDailyRow,
OrganicoPillar,
OrganicoSection,
OrganicoSectionId,
OrganicoWorkspace,
SeoAuditRow,
formatNum,
formatPct,
groupsForPillar,
sumOrganic,
DEFAULT_EXPERT_PRIORITIES,
} from '@/lib/organic';
import { isoYesterday } from '@/lib/ad-tracker-range';
import {
AlertTriangle,
ArrowRight,
Bot,
Check,
@@ -11,27 +30,10 @@ import {
Loader2,
Plus,
Search,
ShieldAlert,
Sparkles,
} from 'lucide-react';
import { PageHeader, SegmentedControl } from '@/components/ui';
import {
GEO_ENGINES,
ORGANICO_SECTIONS,
SEO_FOUNDATIONS,
GeoSnapshot,
IntegrationLite,
OrganicDailyRow,
OrganicoPillar,
OrganicoSection,
OrganicoSectionId,
OrganicoWorkspace,
formatNum,
formatPct,
groupsForPillar,
sumOrganic,
DEFAULT_EXPERT_PRIORITIES,
} from '@/lib/organic';
import { isoYesterday } from '@/lib/ad-tracker-range';
type Props = {
label: string;
@@ -103,6 +105,9 @@ export default function OrganicoDashboard({
const [ga4Landings, setGa4Landings] = useState<
{ page: string; sessions: number; users: number }[]
>([]);
const [seoAudits, setSeoAudits] = useState<SeoAuditRow[]>([]);
const [selectedAuditId, setSelectedAuditId] = useState<string | null>(null);
const [auditing, setAuditing] = useState(false);
const [sectionNote, setSectionNote] = useState('');
const [prioritiesText, setPrioritiesText] = useState('');
const [saving, setSaving] = useState(false);
@@ -147,6 +152,10 @@ export default function OrganicoDashboard({
setGscQueries(data.gscQueries || []);
setGscPages(data.gscPages || []);
setGa4Landings(data.ga4Landings || []);
setSeoAudits(data.seoAudits || []);
if (data.seoAudits?.[0]?.id) {
setSelectedAuditId((prev) => prev || data.seoAudits[0].id);
}
setWarning(data.warning || null);
if (data.autoSynced?.daysWritten) {
setMsg(
@@ -282,6 +291,26 @@ export default function OrganicoDashboard({
});
}
async function runSeoAudit() {
setAuditing(true);
setMsg(null);
const res = await fetch('/api/organico', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ kind: 'seo-audit' }),
});
const data = await res.json();
setAuditing(false);
if (!res.ok) {
setMsg(data.error || 'Error al auditar');
return;
}
if (data.warning) setWarning(data.warning);
setMsg(data.audit?.summary || 'Auditoría guardada');
if (data.audit?.id) setSelectedAuditId(data.audit.id);
await load();
}
async function saveOrganicDay() {
setSaving(true);
setMsg(null);
@@ -624,49 +653,16 @@ export default function OrganicoDashboard({
)}
{section.live === 'seo-foundations' && (
<div className="space-y-3">
<p className="text-xs text-gray-600">
Checklist de auditoría operativa. El estado «OK» de medición
refleja si GA4 + GSC están conectados; el resto lo cierra el equipo.
</p>
<ul className="grid grid-cols-1 md:grid-cols-2 gap-2">
{SEO_FOUNDATIONS.map((f) => {
const ok =
f.id === 'medicion' &&
ga4?.status === 'connected' &&
gsc?.status === 'connected';
return (
<li
key={f.id}
className={`rounded-lg border px-3 py-2.5 ${
ok
? 'border-emerald-200 bg-emerald-50'
: 'border-gray-200 bg-white'
}`}
>
<p className="text-sm font-semibold text-gray-900">
{f.title}
{ok ? (
<span className="ml-2 text-[10px] uppercase text-emerald-700">
OK
</span>
) : (
<span className="ml-2 text-[10px] uppercase text-amber-700">
Revisar
</span>
)}
</p>
<p className="text-[11px] text-gray-600 mt-0.5">{f.hint}</p>
</li>
);
})}
</ul>
<div className="rounded-xl border border-gray-200 bg-white p-3 text-xs text-gray-700 space-y-1">
<p className="font-semibold text-gray-900">Estado conexiones</p>
<p>GSC: {gsc?.status === 'connected' ? 'conectado' : 'sin conectar'}{gsc?.external_account_id ? ` · ${gsc.external_account_id}` : ''}</p>
<p>GA4: {ga4?.status === 'connected' ? 'conectado' : 'sin conectar'}{ga4?.external_account_id ? ` · ${ga4.external_account_id}` : ''}</p>
</div>
</div>
<SeoAuditPanel
audits={seoAudits}
selectedId={selectedAuditId}
onSelect={setSelectedAuditId}
onRun={runSeoAudit}
auditing={auditing}
isAgency={isAgency}
gsc={gsc}
ga4={ga4}
/>
)}
{section.live === 'seo-rankings' && (
@@ -1264,6 +1260,224 @@ function SectionHero({ section }: { section: OrganicoSection }) {
);
}
function auditStatusClass(status?: string) {
if (status === 'ok') return 'border-emerald-200 bg-emerald-50 text-emerald-900';
if (status === 'critical') return 'border-red-200 bg-red-50 text-red-900';
if (status === 'warn') return 'border-amber-200 bg-amber-50 text-amber-950';
return 'border-sky-100 bg-sky-50 text-sky-900';
}
function auditStatusLabel(status?: string) {
if (status === 'ok') return 'OK';
if (status === 'critical') return 'Crítico';
if (status === 'warn') return 'Aviso';
return 'Info';
}
function SeoAuditPanel({
audits,
selectedId,
onSelect,
onRun,
auditing,
isAgency,
gsc,
ga4,
}: {
audits: SeoAuditRow[];
selectedId: string | null;
onSelect: (id: string) => void;
onRun: () => void;
auditing: boolean;
isAgency: boolean;
gsc?: IntegrationLite;
ga4?: IntegrationLite;
}) {
const selected =
audits.find((a) => a.id === selectedId) || audits[0] || null;
const criticals =
selected?.checks?.filter((c) => c.status === 'critical') || [];
const warns = selected?.checks?.filter((c) => c.status === 'warn') || [];
return (
<div className="space-y-4">
<div className="flex flex-wrap items-center justify-between gap-3">
<div>
<p className="text-xs text-gray-600">
Auditoría técnica real: HTTPS, robots, sitemap, title/H1/meta,
indexación y top páginas GSC. Cada ejecución queda en el histórico.
</p>
<p className="text-[11px] text-gray-500 mt-1">
GSC:{' '}
{gsc?.status === 'connected' ? 'conectado' : 'sin conectar'}
{gsc?.external_account_id
? ` · ${gsc.external_account_id}`
: ''}{' '}
· GA4:{' '}
{ga4?.status === 'connected' ? 'conectado' : 'sin conectar'}
</p>
</div>
{isAgency ? (
<button
type="button"
disabled={auditing}
onClick={onRun}
className="inline-flex items-center gap-1.5 text-sm font-semibold px-4 py-2 rounded-lg bg-brand-500 text-white disabled:opacity-50"
>
{auditing ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : (
<Search className="w-4 h-4" />
)}
{auditing ? 'Auditando…' : 'Ejecutar auditoría'}
</button>
) : null}
</div>
{!selected ? (
<EmptyHint
text={
isAgency
? 'Aún no hay auditorías. Pulsa «Ejecutar auditoría» para generar la primera y guardar el histórico.'
: 'Aún no hay auditorías de sitio para este cliente.'
}
/>
) : (
<>
{(Number(selected.critical_count) > 0 ||
Number(selected.warning_count) > 0) && (
<div
className={`rounded-xl border px-3 py-2.5 text-sm flex gap-2 items-start ${
Number(selected.critical_count) > 0
? 'border-red-200 bg-red-50 text-red-900'
: 'border-amber-200 bg-amber-50 text-amber-950'
}`}
>
{Number(selected.critical_count) > 0 ? (
<ShieldAlert className="w-4 h-4 mt-0.5 shrink-0" />
) : (
<AlertTriangle className="w-4 h-4 mt-0.5 shrink-0" />
)}
<div>
<p className="font-semibold">
{Number(selected.critical_count) > 0
? `${selected.critical_count} problema(s) crítico(s)`
: `${selected.warning_count} aviso(s)`}
</p>
<p className="text-xs mt-0.5 opacity-90">
{selected.summary ||
'Revisa los checks marcados abajo antes del siguiente sprint SEO.'}
</p>
</div>
</div>
)}
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3">
<Kpi
label="Nota"
value={`${formatNum(Number(selected.score) || 0)}/100`}
hint={`Grado ${selected.grade || '—'}`}
accent
/>
<Kpi
label="Críticos"
value={formatNum(Number(selected.critical_count) || 0)}
/>
<Kpi
label="Avisos"
value={formatNum(Number(selected.warning_count) || 0)}
/>
<Kpi
label="Sitio"
value={(selected.site_url || '—').replace(/^https?:\/\//, '').slice(0, 22)}
hint={
selected.created_at
? new Date(selected.created_at).toLocaleString('es-ES')
: undefined
}
/>
</div>
{audits.length > 1 && (
<div className="rounded-xl border border-gray-200 bg-white p-3">
<p className="text-[10px] uppercase tracking-wider text-gray-500 mb-2">
Histórico ({audits.length})
</p>
<div className="flex flex-wrap gap-1.5">
{audits.map((a) => {
const active = (selected?.id || '') === a.id;
const bad = Number(a.critical_count) > 0;
return (
<button
key={a.id || a.created_at}
type="button"
onClick={() => a.id && onSelect(a.id)}
className={`text-left rounded-lg border px-2.5 py-1.5 text-xs transition ${
active
? 'border-brand-400 bg-brand-50 text-brand-900'
: 'border-gray-200 bg-gray-50 text-gray-700 hover:border-gray-300'
}`}
>
<span className="font-semibold tabular-nums">
{a.score}/100 · {a.grade}
</span>
{bad ? (
<span className="ml-1 text-red-600">!</span>
) : null}
<span className="block text-[10px] text-gray-500">
{a.created_at
? new Date(a.created_at).toLocaleDateString('es-ES', {
day: '2-digit',
month: 'short',
hour: '2-digit',
minute: '2-digit',
})
: '—'}
</span>
</button>
);
})}
</div>
</div>
)}
<div className="space-y-2">
<p className="text-[10px] uppercase tracking-wider text-gray-500">
Checks ({selected.checks?.length || 0})
{criticals.length || warns.length
? ` · ${criticals.length} críticos · ${warns.length} avisos`
: ''}
</p>
<ul className="grid grid-cols-1 md:grid-cols-2 gap-2">
{(selected.checks || []).map((c) => (
<li
key={c.id}
className={`rounded-lg border px-3 py-2.5 ${auditStatusClass(c.status)}`}
>
<div className="flex items-start justify-between gap-2">
<p className="text-sm font-semibold">
{c.title}
<span className="ml-2 text-[10px] uppercase opacity-80">
{auditStatusLabel(c.status)}
</span>
</p>
<span className="text-[10px] uppercase tracking-wide opacity-70 shrink-0">
{c.category}
</span>
</div>
<p className="text-[11px] mt-0.5 opacity-90 leading-snug">
{c.detail}
</p>
</li>
))}
</ul>
</div>
</>
)}
</div>
);
}
function EmptyHint({ text }: { text: string }) {
return (
<p className="text-sm text-gray-600 border border-dashed border-gray-300 rounded-xl px-4 py-6 text-center bg-gray-50">

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