#03 Cómo construyo un modelo de priorización de URLs basado en datos de crawl + GSC

SEO TécnicoPythonGoogle Search ConsoleCrawlData SciencePriorización

Cómo construyo un modelo de priorización de URLs basado en datos de crawl + GSC

Una de las preguntas que más me hacen cuando audito un sitio grande es: ¿por dónde empezamos? Y es una buena pregunta. Cuando tienes 10.000 URLs, no puedes optimizarlas todas a la vez. Necesitas un criterio claro para decidir dónde poner el foco primero.

Durante mucho tiempo ese criterio era más intuitivo que sistemático. Revisaba las páginas con más tráfico, las que tenían errores obvios, las que el cliente mencionaba en la reunión. Funcionaba, pero no escalaba y, sobre todo, no era reproducible.

Ahora tengo un modelo. No es complejo, pero sí es consistente. Te lo cuento.

La lógica detrás del modelo

La idea central es simple: una URL merece atención prioritaria cuando tiene potencial real de mejora y cuando esa mejora tiene impacto de negocio. Para medir eso, necesito dos fuentes de datos combinadas:

  • Datos de crawl: estado técnico de la URL (indexabilidad, velocidad, errores, estructura interna)
  • Datos de GSC: rendimiento actual en búsqueda (impresiones, clics, posición media, CTR)

Por separado, cada fuente cuenta la mitad de la historia. Juntas, te dan una visión completa de dónde hay problemas y cuánto te cuesta tenerlos sin resolver.

Las fuentes de datos

Crawl con Screaming Frog

Exporto el crawl completo en CSV desde Screaming Frog. Las columnas que me interesan son:

  • Address: URL de la página
  • Status Code: para detectar errores 4xx, 5xx o redirecciones
  • Indexability: si la URL es indexable o no (y el motivo)
  • Word Count: cantidad de contenido
  • Response Time: velocidad de respuesta
  • Inlinks: número de enlaces internos que recibe

Datos de GSC vía API

Para el lado del rendimiento, uso la API de Search Console y traigo los datos agrupados por URL (dimensión page):

from google.oauth2 import service_account
from googleapiclient.discovery import build
import pandas as pd

SCOPES = ["https://www.googleapis.com/auth/webmasters.readonly"]
SERVICE_ACCOUNT_FILE = "credentials.json"

credentials = service_account.Credentials.from_service_account_file(
    SERVICE_ACCOUNT_FILE, scopes=SCOPES
)

service = build("searchconsole", "v1", credentials=credentials)

response = service.searchanalytics().query(
    siteUrl="https://tusitio.com",
    body={
        "startDate": "2025-12-01",
        "endDate": "2026-05-30",
        "dimensions": ["page"],
        "rowLimit": 25000
    }
).execute()

rows = response.get("rows", [])
gsc_df = pd.DataFrame([{
    "url": r["keys"][0],
    "clicks": r["clicks"],
    "impressions": r["impressions"],
    "ctr": r["ctr"],
    "position": r["position"]
} for r in rows])

Construir el modelo paso a paso

1. Cargar y cruzar los datos

import pandas as pd

# Cargar crawl
crawl_df = pd.read_csv("screaming_frog_export.csv", low_memory=False)
crawl_df = crawl_df.rename(columns={
    "Address": "url",
    "Status Code": "status_code",
    "Indexability": "indexability",
    "Word Count": "word_count",
    "Response Time": "response_time",
    "Inlinks": "inlinks"
})

# Cruzar con GSC por URL
df = crawl_df.merge(gsc_df, on="url", how="left")

# Rellenar NaN en columnas de GSC (URLs sin datos de rendimiento)
df[["clicks", "impressions", "ctr", "position"]] = df[[
    "clicks", "impressions", "ctr", "position"
]].fillna(0)

2. Crear señales de oportunidad

Aquí defino las señales que me indican que una URL tiene potencial de mejora. Cada señal es una columna binaria (0 o 1) que luego combino en una puntuación final.

# Señal 1: URL indexable con impresiones pero sin clics
df["s_impresiones_sin_clics"] = (
    (df["indexability"] == "Indexable") &
    (df["impressions"] > 50) &
    (df["clicks"] == 0)
).astype(int)

# Señal 2: Posición entre 6 y 20 (cerca del top pero fuera)
df["s_posicion_near_top"] = (
    (df["position"] >= 6) &
    (df["position"] <= 20)
).astype(int)

# Señal 3: CTR por debajo del 2% en posiciones 1-5
df["s_ctr_bajo_top5"] = (
    (df["position"] <= 5) &
    (df["ctr"] < 0.02)
).astype(int)

# Señal 4: Contenido escaso (menos de 300 palabras)
df["s_contenido_escaso"] = (df["word_count"] < 300).astype(int)

# Señal 5: Pocos enlaces internos (URL poco reforzada)
df["s_pocos_inlinks"] = (df["inlinks"] < 3).astype(int)

# Señal 6: Tiempo de respuesta alto
df["s_lenta"] = (df["response_time"] > 2000).astype(int)  # en ms

3. Calcular la puntuación de prioridad

Asigno un peso a cada señal según su impacto esperado. Estos pesos los ajusto proyecto a proyecto según el contexto del cliente, pero estos son mis valores de partida:

pesos = {
    "s_impresiones_sin_clics": 3,
    "s_posicion_near_top": 3,
    "s_ctr_bajo_top5": 2,
    "s_contenido_escaso": 2,
    "s_pocos_inlinks": 1,
    "s_lenta": 1
}

df["score"] = sum(df[señal] * peso for señal, peso in pesos.items())

Además, incorporo el volumen de impresiones como multiplicador. Una URL con score alto pero sin visibilidad es menos urgente que una con el mismo score y miles de impresiones.

import numpy as np

df["score_ponderado"] = df["score"] * np.log1p(df["impressions"])

4. Filtrar y ordenar el resultado

# Solo URLs indexables con alguna señal activa
resultado = df[
    (df["indexability"] == "Indexable") &
    (df["score"] > 0)
].sort_values("score_ponderado", ascending=False)

columnas_salida = [
    "url", "clicks", "impressions", "ctr", "position",
    "word_count", "inlinks", "response_time",
    "score", "score_ponderado"
]

resultado[columnas_salida].to_csv("urls_priorizadas.csv", index=False)

El resultado es una lista ordenada de URLs donde la primera es, según el modelo, la que más impacto puede tener si la trabajas ahora. No es una verdad absoluta, pero sí un criterio objetivo y consistente para arrancar.

Cómo uso el output en el día a día

El CSV exportado lo abro en Google Sheets y lo comparto con el equipo o con el cliente. Las primeras 20-30 URLs del ranking suelen ser las que entran en el sprint de optimización del mes. Para cada una, las señales activas me dicen exactamente qué tipo de acción aplicar:

  • s_contenido_escaso activado → ampliar y mejorar el contenido
  • s_pocos_inlinks activado → reforzar el enlazado interno
  • s_ctr_bajo_top5 activado → reescribir título y metadescripción
  • s_posicion_near_top activado → revisar relevancia semántica del contenido

Lo que este modelo no resuelve

Seré honesto: el modelo no sabe nada de negocio. Una URL con score bajo puede ser estratégicamente crítica para el cliente aunque los datos no la destaquen. Y al revés: una URL en el top del ranking puede ser una página secundaria que no mueve la aguja comercialmente.

Por eso siempre presento el modelo como una herramienta de soporte, no como un oráculo. El criterio estratégico sigue siendo tuyo. El modelo solo te evita tomar decisiones a ciegas.


En el siguiente post voy un paso más allá y te cuento cómo detecto canibalización a escala usando pandas. Si este proceso te ha resultado útil, ese te va a gustar más todavía.