Files
4ventures-client-portal/src/lib/organic.ts
4Ventures 06024cf473 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>
2026-09-24 16:55:58 +02:00

581 lines
16 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/** Types + helpers for SEO (GA4/GSC) and GEO (AI visibility). */
export type OrganicDailyRow = {
day?: string;
date?: string;
gsc_clicks?: number;
gsc_impressions?: number;
gsc_ctr?: number | null;
gsc_avg_position?: number | null;
ga4_sessions?: number;
ga4_users?: number;
ga4_organic_sessions?: number;
ga4_conversions?: number;
source?: string;
};
export type GeoEngineScores = {
chatgpt?: number;
gemini?: number;
ai_overviews?: number;
};
export type GeoCompetitor = {
brand: string;
share: number;
rank?: number;
};
export type GeoCategory = {
category: string;
visibility: number;
rank?: number;
};
export type GeoSource = {
name: string;
share: number;
kind?: 'official' | 'pharmacy' | 'media' | 'other';
};
export type GeoSnapshot = {
id?: string;
tenant_id?: string;
label?: string;
period_start?: string | null;
period_end?: string | null;
share_of_model?: number | null;
engines?: GeoEngineScores;
official_citation_pct?: number | null;
sentiment_score?: number | null;
competitors?: GeoCompetitor[];
categories?: GeoCategory[];
top_sources?: GeoSource[];
prompts_analyzed?: number | null;
responses_analyzed?: number | null;
study_url?: string | null;
notes?: string | null;
created_at?: string;
};
export type IntegrationLite = {
provider: string;
status?: string;
enabled?: boolean;
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 {
const x = Number(v);
return Number.isFinite(x) ? x : 0;
}
export type OrganicTotals = {
gscClicks: number;
gscImpressions: number;
gscCtr: number;
gscAvgPosition: number;
ga4Sessions: number;
ga4Users: number;
ga4Organic: number;
ga4Conversions: number;
days: number;
};
export function sumOrganic(rows: OrganicDailyRow[]): OrganicTotals {
let clicks = 0;
let impr = 0;
let posW = 0;
let posN = 0;
let sessions = 0;
let users = 0;
let organic = 0;
let conv = 0;
for (const r of rows) {
clicks += n(r.gsc_clicks);
impr += n(r.gsc_impressions);
const pos = r.gsc_avg_position;
if (pos != null && Number.isFinite(Number(pos))) {
posW += Number(pos) * Math.max(1, n(r.gsc_impressions));
posN += Math.max(1, n(r.gsc_impressions));
}
sessions += n(r.ga4_sessions);
users += n(r.ga4_users);
organic += n(r.ga4_organic_sessions);
conv += n(r.ga4_conversions);
}
return {
gscClicks: clicks,
gscImpressions: impr,
gscCtr: impr > 0 ? (100 * clicks) / impr : 0,
gscAvgPosition: posN > 0 ? posW / posN : 0,
ga4Sessions: sessions,
ga4Users: users,
ga4Organic: organic,
ga4Conversions: conv,
days: rows.length,
};
}
export function formatNum(v: number, digits = 0): string {
return v.toLocaleString('es-ES', {
maximumFractionDigits: digits,
minimumFractionDigits: digits,
});
}
export function formatPct(v: number, digits = 1): string {
return `${formatNum(v, digits)}%`;
}
export const SEO_FOUNDATIONS = [
{
id: 'indexacion',
title: 'Indexación y rastreo',
hint: 'Sitemap, robots, casuísticas de URLs dinámicas',
},
{
id: 'arquitectura',
title: 'Arquitectura y enlazado interno',
hint: 'Distribuir autoridad hacia landings clave',
},
{
id: 'render',
title: 'Renderizado y Core Web Vitals',
hint: 'Velocidad, JS crítico, UX móvil',
},
{
id: 'contenido',
title: 'Contenido indexable',
hint: 'Guías, FAQs, fichas que la IA y Google puedan citar',
},
{
id: 'medicion',
title: 'Medición GA4 + GSC',
hint: 'Property + propiedad Search Console conectadas',
},
] as const;
export const GEO_ENGINES = [
{ id: 'chatgpt', label: 'ChatGPT' },
{ id: 'gemini', label: 'Gemini' },
{ id: 'ai_overviews', label: 'Google AI Overviews' },
] as const;
/* ── Product IA (Semrush-style) ─────────────────────────────────── */
export type OrganicoPillar = 'seo' | 'geo';
export type OrganicoSectionId =
// GEO
| 'geo-overview'
| 'geo-competition'
| 'geo-prompts'
| 'geo-brand'
| 'geo-perception'
| 'geo-narrative'
| 'geo-questions'
| 'geo-audit'
| 'geo-tracking'
| 'geo-content'
| 'geo-roi'
// SEO
| 'seo-overview'
| 'seo-audit'
| 'seo-rankings'
| 'seo-competition'
| 'seo-domain'
| 'seo-organic'
| 'seo-pages'
| 'seo-compare'
| 'seo-keyword-gap'
| 'seo-backlink-gap'
| 'seo-keywords'
| 'seo-keyword-magic'
| 'seo-strategy'
| 'seo-content-ideas'
| 'seo-writing'
| 'seo-topics'
| 'seo-template'
| 'seo-backlinks'
| 'seo-refdomains'
| 'seo-backlink-audit'
| 'seo-onpage'
| 'seo-traffic-insights';
export type OrganicoSection = {
id: OrganicoSectionId;
pillar: OrganicoPillar;
group: string;
label: string;
blurb: string;
/** What the user should do next when empty. */
actions: string[];
live?:
| 'seo-metrics'
| 'seo-foundations'
| 'seo-rankings'
| 'seo-trend'
| 'seo-pages'
| 'seo-queries'
| 'seo-cross'
| 'seo-content'
| 'geo-metrics'
| 'geo-competitors'
| 'geo-categories';
soon?: boolean;
};
export const ORGANICO_SECTIONS: OrganicoSection[] = [
// ——— GEO ———
{
id: 'geo-overview',
pillar: 'geo',
group: 'Visibilidad en la IA',
label: 'Visión general',
blurb:
'Monitoriza la visibilidad de tu marca en la IA. Share of Model, motores y prioridades claras de mejora.',
actions: [
'Registrar medición GEO (Share of Model + motores)',
'Priorizar 3 prompts donde no apareces',
'Revisar % de citas a web oficial',
],
live: 'geo-metrics',
},
{
id: 'geo-competition',
pillar: 'geo',
group: 'Visibilidad en la IA',
label: 'Análisis de la competencia',
blurb:
'Identifica prompts, menciones y fuentes donde tus rivales aparecen pero tú no, y reduce esa brecha.',
actions: [
'Listar competidores con más Share of Voice',
'Mapear fuentes que citan a rivales y no a ti',
'Definir 5 acciones para cerrar la brecha',
],
live: 'geo-competitors',
},
{
id: 'geo-prompts',
pillar: 'geo',
group: 'Visibilidad en la IA',
label: 'Investigación de prompts',
blurb:
'Descubre los prompts que generan conversaciones de IA en tu sector y las fuentes de las respuestas.',
actions: [
'Clusterizar prompts por intención (informativa / compra / comparativa)',
'Marcar prompts prioritarios por volumen o negocio',
'Anotar marcas y URLs citadas en cada respuesta',
],
soon: true,
},
{
id: 'geo-brand',
pillar: 'geo',
group: 'Rendimiento de marca',
label: 'Rendimiento de marca',
blurb:
'Verifica puntuaciones de visibilidad en IA y los prompts más relevantes para tu marca.',
actions: [
'Comparar Share of Model vs top 3',
'Filtrar menciones por plataforma (ChatGPT / Gemini / AIO)',
'Detectar caídas día a día cuando haya rastreo',
],
live: 'geo-metrics',
},
{
id: 'geo-perception',
pillar: 'geo',
group: 'Rendimiento de marca',
label: 'Percepción',
blurb:
'Evalúa sentimiento positivo/negativo y cómo te percibe cada plataforma de IA.',
actions: [
'Clasificar menciones + / − / neutro',
'Anotar narrativas por motor',
'Priorizar correcciones de reputación',
],
},
{
id: 'geo-narrative',
pillar: 'geo',
group: 'Rendimiento de marca',
label: 'Elementos narrativos',
blurb:
'Aduéñate de la narrativa: mensajes, claims y atributos que la IA asocia a tu marca.',
actions: [
'Extraer claims recurrentes en respuestas',
'Contrastar con messaging oficial',
'Definir mensajes a reforzar en contenido citables',
],
},
{
id: 'geo-questions',
pillar: 'geo',
group: 'Rendimiento de marca',
label: 'Preguntas',
blurb:
'Las preguntas reales de la audiencia orientan el contenido que la IA citará.',
actions: [
'Listar top preguntas por categoría',
'Crear FAQ / guías que respondan con datos propios',
'Enlazar a landings de conversión',
],
live: 'geo-categories',
},
{
id: 'geo-audit',
pillar: 'geo',
group: 'Impulso y monitorización',
label: 'Auditoría del sitio (GEO)',
blurb:
'Elimina barreras a la visibilidad: que la IA entienda, confíe y cite tu web.',
actions: [
'Revisar schema, autoría y E-E-A-T',
'Asegurar respuestas claras y citables en páginas clave',
'Mejorar velocidad y acceso de crawlers',
],
live: 'seo-foundations',
},
{
id: 'geo-tracking',
pillar: 'geo',
group: 'Impulso y monitorización',
label: 'Rastreo de prompts',
blurb:
'Rastrea visibilidad a diario, detecta prompts prioritarios y obtén recomendaciones de crecimiento.',
actions: [
'Fijar cartera de prompts a monitorizar',
'Registrar aparición / no aparición por motor',
'Alertar caídas de Share of Model',
],
},
{
id: 'geo-content',
pillar: 'geo',
group: 'Impulso y monitorización',
label: 'Creación de contenidos',
blurb:
'Prepara contenido para búsqueda de IA: fortalece visibilidad y sentimiento.',
actions: [
'Briefs a partir de prompts ganadores de competidores',
'Optimizar para cita (datos, definiciones, tablas)',
'Publicar y re-medir Share of Model',
],
},
{
id: 'geo-roi',
pillar: 'geo',
group: 'Impulso y monitorización',
label: 'ROI de visibilidad IA',
blurb:
'Informes de visibilidad IA junto a tráfico y conversiones (GA4). Disponible en breve.',
actions: [
'Conectar GA4',
'Cruzar sesiones orgánicas con picos de menciones IA',
'Informe ejecutivo con un clic (próximamente)',
],
soon: true,
},
// ——— SEO (solo secciones con datos reales GSC/GA4 o checklist operable) ———
{
id: 'seo-overview',
pillar: 'seo',
group: 'Rendimiento del sitio',
label: 'Visión general',
blurb:
'KPIs diarios GA4 + GSC. El histórico vive en organic_daily (nosotros lo acumulamos; la API de GSC sola no basta).',
actions: [
'Conectar Search Console y GA4',
'Revisar serie 28d',
'Definir prioridades del mes',
],
live: 'seo-metrics',
},
{
id: 'seo-audit',
pillar: 'seo',
group: 'Rendimiento del sitio',
label: 'Auditoría del sitio',
blurb:
'Checklist operativo de salud SEO (indexación, arquitectura, CWV, contenido, medición). No es un crawl Semrush: es la base que hay que cerrar con los datos conectados.',
actions: [
'Marcar OK/KO por bloque',
'Priorizar indexación + medición',
'CWV en landings de conversión',
],
live: 'seo-foundations',
},
{
id: 'seo-rankings',
pillar: 'seo',
group: 'Rendimiento del sitio',
label: 'Rastreo de posición',
blurb:
'Sí: posición media diaria en organic_daily (histórico nuestro) + ranking actual de queries GSC. No es un rank tracker de 10k KW tipo Semrush; es lo que GSC reporta para tus impresiones reales.',
actions: [
'Mirar tendencia de posición media',
'Atacar queries en pos. 4–15 (quick wins)',
'Ampliar cartera vía contenido',
],
live: 'seo-rankings',
},
{
id: 'seo-organic',
pillar: 'seo',
group: 'Tráfico y páginas',
label: 'Posicionamiento orgánico',
blurb:
'Evolución de clics e impresiones (visibilidad). Distinto de «Páginas principales».',
actions: [
'Leer tendencia MoM de clics',
'Cruzar con campañas de contenido',
'Alertar caídas bruscas',
],
live: 'seo-trend',
},
{
id: 'seo-pages',
pillar: 'seo',
group: 'Tráfico y páginas',
label: 'Páginas principales',
blurb:
'URLs que concentran clics GSC + landings orgánicas GA4. No es el mismo panel que posicionamiento.',
actions: [
'Optimizar top 10 URLs',
'Alinear title/H1/CTA',
'Enlazar desde hubs',
],
live: 'seo-pages',
},
{
id: 'seo-keywords',
pillar: 'seo',
group: 'Keywords',
label: 'Visión general de keywords',
blurb:
'Queries reales con impresiones/clics/CTR/posición desde Search Console (28d).',
actions: [
'Mapear KW → URL',
'Separar informativas vs compra',
'Priorizar quick wins',
],
live: 'seo-queries',
},
{
id: 'seo-traffic-insights',
pillar: 'seo',
group: 'Keywords',
label: 'Organic Traffic Insights',
blurb:
'Cruce GSC (páginas) + GA4 (sesiones orgánicas / landings) para ver qué rankea y qué convierte.',
actions: [
'Comparar top pages GSC vs landings GA4',
'Detectar páginas con clics y poca conversión',
'Informar ROI orgánico',
],
live: 'seo-cross',
},
{
id: 'seo-competition',
pillar: 'seo',
group: 'Competencia y enlaces',
label: 'Competencia / backlinks',
blurb:
'Requiere Ahrefs/Semrush u otra fuente de backlinks y dominio rival. No está en GSC/GA4. Aquí solo el marco de trabajo hasta integrar una herramienta.',
actions: [
'Fijar 3 dominios rivales',
'Importar gap KW / backlinks desde herramienta externa',
'Priorizar 5 acciones/mes',
],
soon: true,
},
{
id: 'seo-content-ideas',
pillar: 'seo',
group: 'Contenido',
label: 'Ideas de contenido',
blurb:
'Briefs a partir de queries GSC con tracción (clics o impresiones altas y CTR bajo).',
actions: [
'Partir de queries con CTR bajo',
'Crear/ampliar pieza',
'Medir de nuevo en 28d',
],
live: 'seo-content',
},
];
export function sectionsForPillar(pillar: OrganicoPillar): OrganicoSection[] {
return ORGANICO_SECTIONS.filter((s) => s.pillar === pillar);
}
export function groupsForPillar(
pillar: OrganicoPillar
): { group: string; items: OrganicoSection[] }[] {
const list = sectionsForPillar(pillar);
const order: string[] = [];
const map = new Map<string, OrganicoSection[]>();
for (const s of list) {
if (!map.has(s.group)) {
map.set(s.group, []);
order.push(s.group);
}
map.get(s.group)!.push(s);
}
return order.map((group) => ({ group, items: map.get(group)! }));
}
export type OrganicoWorkspace = {
priorities?: { seo: string[]; geo: string[] };
sectionNotes?: Partial<Record<OrganicoSectionId, string>>;
};
/** Prioridades por defecto (SEO/GEO expert) cuando el workspace está vacío. */
export const DEFAULT_EXPERT_PRIORITIES: { seo: string[]; geo: string[] } = {
seo: [
'Verificar GSC (URL-prefix vs sc-domain) y GA4 property: datos diarios en organic_daily',
'Top landings por clics GSC: title/H1/CTA alineados con intención de búsqueda',
'Cerrar gap de indexación: sitemap, noindex accidental, soft-404',
'Core Web Vitals móvil en landings de conversión (LCP < 2.5s)',
'Keyword gap vs 3 competidores: 10 URLs nuevas o ampliadas este mes',
'Enlazado interno desde hub → money pages (autoridad hacia conversión)',
'Cruzar sesiones orgánicas GA4 con leads CRM (mismo embudo que ads)',
],
geo: [
'Definir cartera de 30–50 prompts de compra/comparativa del sector',
'Medir Share of Model en ChatGPT, Gemini y AI Overviews (baseline)',
'Aumentar % de citas a web oficial (schema, autoría, datos propios)',
'FAQs citables: respuesta directa + tabla/dato en las 10 URLs clave',
'Mapear fuentes que citan a competidores y no a la marca',
'Briefs de contenido a partir de prompts donde SoM = 0',
'Re-medir mensualmente y priorizar caídas de sentimiento / menciones',
],
};