From 06024cf473079ed3c61f2f08ab62937b08b09741 Mon Sep 17 00:00:00 2001 From: 4Ventures <4ventures@MacBook-Air-de-4Ventures.local> Date: Thu, 24 Sep 2026 16:55:58 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20auditor=C3=ADa=20SEO=20real=20con=20his?= =?UTF-8?q?t=C3=B3rico=20y=20avisos?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Crawl técnico (HTTPS/robots/sitemap/on-page) + tabla seo_audits; UI con nota, checks y alertas. Co-authored-by: Cursor --- scripts/dokploy_apply_seo_audits.py | 99 +++++++ sql/seo_audits.sql | 31 ++ src/app/api/organico/route.ts | 183 +++++++++++- src/components/OrganicoDashboard.tsx | 336 ++++++++++++++++++---- src/lib/organic.ts | 22 ++ src/lib/seo-audit.ts | 404 +++++++++++++++++++++++++++ 6 files changed, 1008 insertions(+), 67 deletions(-) create mode 100644 scripts/dokploy_apply_seo_audits.py create mode 100644 sql/seo_audits.sql create mode 100644 src/lib/seo-audit.ts diff --git a/scripts/dokploy_apply_seo_audits.py b/scripts/dokploy_apply_seo_audits.py new file mode 100644 index 0000000..c52a5c6 --- /dev/null +++ b/scripts/dokploy_apply_seo_audits.py @@ -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()) diff --git a/sql/seo_audits.sql b/sql/seo_audits.sql new file mode 100644 index 0000000..bc41b6e --- /dev/null +++ b/sql/seo_audits.sql @@ -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). diff --git a/src/app/api/organico/route.ts b/src/app/api/organico/route.ts index 84c0d9a..b2ed58f 100644 --- a/src/app/api/organico/route.ts +++ b/src/app/api/organico/route.ts @@ -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 = {}; + 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 } | 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 = {}; + 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) + : {}; + 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( diff --git a/src/components/OrganicoDashboard.tsx b/src/components/OrganicoDashboard.tsx index ff94f88..aa045bc 100644 --- a/src/components/OrganicoDashboard.tsx +++ b/src/components/OrganicoDashboard.tsx @@ -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([]); + const [selectedAuditId, setSelectedAuditId] = useState(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' && ( -
-

- Checklist de auditoría operativa. El estado «OK» de medición - refleja si GA4 + GSC están conectados; el resto lo cierra el equipo. -

-
    - {SEO_FOUNDATIONS.map((f) => { - const ok = - f.id === 'medicion' && - ga4?.status === 'connected' && - gsc?.status === 'connected'; - return ( -
  • -

    - {f.title} - {ok ? ( - - OK - - ) : ( - - Revisar - - )} -

    -

    {f.hint}

    -
  • - ); - })} -
-
-

Estado conexiones

-

GSC: {gsc?.status === 'connected' ? 'conectado' : 'sin conectar'}{gsc?.external_account_id ? ` · ${gsc.external_account_id}` : ''}

-

GA4: {ga4?.status === 'connected' ? 'conectado' : 'sin conectar'}{ga4?.external_account_id ? ` · ${ga4.external_account_id}` : ''}

-
-
+ )} {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 ( +
+
+
+

+ 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. +

+

+ GSC:{' '} + {gsc?.status === 'connected' ? 'conectado' : 'sin conectar'} + {gsc?.external_account_id + ? ` · ${gsc.external_account_id}` + : ''}{' '} + · GA4:{' '} + {ga4?.status === 'connected' ? 'conectado' : 'sin conectar'} +

+
+ {isAgency ? ( + + ) : null} +
+ + {!selected ? ( + + ) : ( + <> + {(Number(selected.critical_count) > 0 || + Number(selected.warning_count) > 0) && ( +
0 + ? 'border-red-200 bg-red-50 text-red-900' + : 'border-amber-200 bg-amber-50 text-amber-950' + }`} + > + {Number(selected.critical_count) > 0 ? ( + + ) : ( + + )} +
+

+ {Number(selected.critical_count) > 0 + ? `${selected.critical_count} problema(s) crítico(s)` + : `${selected.warning_count} aviso(s)`} +

+

+ {selected.summary || + 'Revisa los checks marcados abajo antes del siguiente sprint SEO.'} +

+
+
+ )} + +
+ + + + +
+ + {audits.length > 1 && ( +
+

+ Histórico ({audits.length}) +

+
+ {audits.map((a) => { + const active = (selected?.id || '') === a.id; + const bad = Number(a.critical_count) > 0; + return ( + + ); + })} +
+
+ )} + +
+

+ Checks ({selected.checks?.length || 0}) + {criticals.length || warns.length + ? ` · ${criticals.length} críticos · ${warns.length} avisos` + : ''} +

+
    + {(selected.checks || []).map((c) => ( +
  • +
    +

    + {c.title} + + {auditStatusLabel(c.status)} + +

    + + {c.category} + +
    +

    + {c.detail} +

    +
  • + ))} +
+
+ + )} +
+ ); +} + function EmptyHint({ text }: { text: string }) { return (

diff --git a/src/lib/organic.ts b/src/lib/organic.ts index 709cb8c..b70b860 100644 --- a/src/lib/organic.ts +++ b/src/lib/organic.ts @@ -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 { diff --git a/src/lib/seo-audit.ts b/src/lib/seo-audit.ts new file mode 100644 index 0000000..2ddc017 --- /dev/null +++ b/src/lib/seo-audit.ts @@ -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( + `]+(?:name|property)=["']${name}["'][^>]*content=["']([^"']*)["']`, + 'i' + ); + const re2 = new RegExp( + `]+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]*?)`, 'i')); + return m ? m[1].replace(/<[^>]+>/g, '').trim() : null; +} + +function hasCanonical(html: string): string | null { + const m = html.match( + /]+rel=["']canonical["'][^>]*href=["']([^"']+)["']/i + ); + const m2 = html.match( + /]+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 { + 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 ', + }); + 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(), + }; +}