Crawl técnico (HTTPS/robots/sitemap/on-page) + tabla seo_audits; UI con nota, checks y alertas. Co-authored-by: Cursor <cursoragent@cursor.com>
581 lines
16 KiB
TypeScript
581 lines
16 KiB
TypeScript
/** 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',
|
||
],
|
||
};
|