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:
99
scripts/dokploy_apply_seo_audits.py
Normal file
99
scripts/dokploy_apply_seo_audits.py
Normal file
@@ -0,0 +1,99 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Apply sql/seo_audits.sql via Dokploy supabase-db terminal."""
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from playwright.sync_api import sync_playwright
|
||||
|
||||
ROOT = Path("/Users/4ventures/4ventures-client-portal")
|
||||
BPS = Path("/Users/4ventures/Projects/bps-facturacion")
|
||||
SQL = ROOT / "sql" / "seo_audits.sql"
|
||||
BASE = "https://dokploy.4ventures.es"
|
||||
|
||||
|
||||
def env(key: str) -> str:
|
||||
v = os.environ.get(key, "").strip()
|
||||
if v:
|
||||
return v
|
||||
p = BPS / ".env"
|
||||
if not p.is_file():
|
||||
return ""
|
||||
for line in p.read_text().splitlines():
|
||||
if line.startswith(f"{key}="):
|
||||
return line.split("=", 1)[1].strip().strip('"').strip("'")
|
||||
return ""
|
||||
|
||||
|
||||
def main() -> int:
|
||||
email = env("DOKPLOY_EMAIL") or "marketing@4ventures.es"
|
||||
password = env("DOKPLOY_PASSWORD")
|
||||
if not password:
|
||||
print("missing DOKPLOY_PASSWORD", flush=True)
|
||||
return 2
|
||||
b64 = base64.b64encode(SQL.read_text(encoding="utf-8").encode()).decode()
|
||||
chunks = [b64[i : i + 180] for i in range(0, len(b64), 180)]
|
||||
|
||||
with sync_playwright() as p:
|
||||
browser = p.chromium.launch(
|
||||
headless=True,
|
||||
channel="chrome",
|
||||
)
|
||||
page = browser.new_page(viewport={"width": 1400, "height": 1000})
|
||||
page.goto(BASE + "/", wait_until="domcontentloaded", timeout=90000)
|
||||
page.wait_for_timeout(800)
|
||||
if "/dashboard" not in page.url:
|
||||
page.locator('input[type="email"], input[name="email"]').first.fill(email)
|
||||
page.locator('input[type="password"]').first.fill(password)
|
||||
page.locator('button[type="submit"]').first.click()
|
||||
page.wait_for_url(re.compile(r"/dashboard"), timeout=90000)
|
||||
print("login ok", flush=True)
|
||||
page.goto(f"{BASE}/dashboard/docker", wait_until="domcontentloaded", timeout=120000)
|
||||
page.wait_for_timeout(800)
|
||||
page.locator("input[placeholder*='ilter' i], input[placeholder*='earch' i]").first.fill(
|
||||
"supabase-db"
|
||||
)
|
||||
page.wait_for_timeout(1000)
|
||||
page.keyboard.press("Escape")
|
||||
page.wait_for_timeout(200)
|
||||
page.get_by_role("button", name=re.compile(r"Open menu", re.I)).first.click()
|
||||
page.wait_for_timeout(400)
|
||||
page.get_by_text("Terminal", exact=True).click()
|
||||
page.wait_for_timeout(1000)
|
||||
if page.locator("button", has_text="Bash").count():
|
||||
page.locator("button", has_text="Bash").first.click()
|
||||
page.wait_for_timeout(2800)
|
||||
if page.locator("textarea.xterm-helper-textarea").count():
|
||||
page.locator("textarea.xterm-helper-textarea").first.click()
|
||||
|
||||
def run(cmd: str, wait: int = 800) -> None:
|
||||
page.keyboard.type(cmd, delay=1)
|
||||
page.keyboard.press("Enter")
|
||||
page.wait_for_timeout(wait)
|
||||
|
||||
run("rm -f /tmp/sa.b64 /tmp/sa.sql; echo READY", 1200)
|
||||
for i, ch in enumerate(chunks):
|
||||
op = ">" if i == 0 else ">>"
|
||||
run(f"printf '%s' '{ch}' {op} /tmp/sa.b64", 400)
|
||||
run(
|
||||
"base64 -d /tmp/sa.b64 > /tmp/sa.sql && "
|
||||
"psql -U postgres -d postgres -v ON_ERROR_STOP=1 -f /tmp/sa.sql; echo EXIT:$?",
|
||||
8000,
|
||||
)
|
||||
text = page.evaluate(
|
||||
"() => (document.querySelector('.xterm-rows')||{}).innerText||''"
|
||||
)
|
||||
browser.close()
|
||||
ok = "EXIT:0" in text
|
||||
print("SQL_OK", ok, flush=True)
|
||||
if not ok:
|
||||
print(text[-2000:], flush=True)
|
||||
return 0 if ok else 6
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
31
sql/seo_audits.sql
Normal file
31
sql/seo_audits.sql
Normal file
@@ -0,0 +1,31 @@
|
||||
-- SEO site audit history (technical checks + score)
|
||||
-- Idempotent.
|
||||
|
||||
create table if not exists public.seo_audits (
|
||||
id uuid primary key default gen_random_uuid(),
|
||||
tenant_id uuid not null references public.tenants(id) on delete cascade,
|
||||
site_url text not null,
|
||||
score numeric not null default 0,
|
||||
grade text not null default 'F',
|
||||
warning_count integer not null default 0,
|
||||
critical_count integer not null default 0,
|
||||
checks jsonb not null default '[]'::jsonb,
|
||||
summary text,
|
||||
created_at timestamptz not null default now()
|
||||
);
|
||||
|
||||
create index if not exists seo_audits_tenant_created_idx
|
||||
on public.seo_audits (tenant_id, created_at desc);
|
||||
|
||||
alter table public.seo_audits enable row level security;
|
||||
|
||||
drop policy if exists seo_audits_tenant_select on public.seo_audits;
|
||||
create policy seo_audits_tenant_select on public.seo_audits
|
||||
for select using (
|
||||
tenant_id::text = coalesce(
|
||||
auth.jwt() -> 'app_metadata' ->> 'tenant_id',
|
||||
auth.jwt() -> 'user_metadata' ->> 'tenant_id'
|
||||
)
|
||||
);
|
||||
|
||||
-- Agency writes via service role (bypasses RLS).
|
||||
@@ -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(
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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