#04 Detección de canibalización a escala con pandas: el método que uso en agencia
Detección de canibalización a escala con pandas: el método que uso en agencia
La canibalización de keywords es uno de esos problemas que todo el mundo conoce pero poca gente detecta bien. No porque sea difícil de entender, sino porque a escala es casi imposible de ver a ojo. Puedes revisar manualmente 50 URLs. No puedes revisar 5.000.
En agencia me encontré con este problema bastante pronto. Clientes con sitios grandes, historiales de contenido largos, y nadie que hubiera auditado la arquitectura desde hacía años. El resultado habitual: varias páginas compitiendo por las mismas queries, Google sin saber a cuál posicionar, y el tráfico repartido entre URLs que deberían estar consolidadas.
Te cuento el método que uso para detectarlo de forma sistemática.
Qué entiendo por canibalización (y qué no)
Antes de entrar en código, quiero ser preciso sobre lo que estamos buscando. Para mí hay canibalización cuando dos o más URLs compiten por la misma keyword con intención equivalente y ninguna de las dos termina de posicionar bien.
Lo que no es canibalización:
- Dos URLs que aparecen por la misma query pero con intenciones distintas (una informacional, otra transaccional)
- Una URL principal bien posicionada y otra secundaria que aparece de forma marginal
- Páginas de paginación o filtros que reciben impresiones residuales
La distinción importa porque si tratas como canibalización todo lo que comparte keyword, acabas consolidando cosas que no deberías tocar.
La fuente de datos: GSC con dimensión query + página
El punto de partida es el mismo export de GSC que usé en posts anteriores, pero aquí es imprescindible tener las dos dimensiones activas: query y página. Sin ese cruce, no puedes saber qué URLs están recibiendo impresiones por la misma keyword.
import pandas as pd
# Cargar el export de GSC con dimensiones query + página
df = pd.read_csv("gsc_query_pagina.csv")
df.columns = ["query", "url", "clicks", "impressions", "ctr", "position"]
# Limpiar CTR
df["ctr"] = df["ctr"].str.replace("%", "").astype(float) / 100
print(df.shape)
print(df.head())
El método paso a paso
1. Identificar queries con más de una URL
La señal más clara de canibalización potencial es que una misma query tenga impresiones repartidas entre varias URLs. Eso lo detecto así:
# Contar cuántas URLs distintas recibe impresiones cada query
urls_por_query = df.groupby("query")["url"].nunique().reset_index()
urls_por_query.columns = ["query", "num_urls"]
# Filtrar solo las queries con más de una URL
queries_canibalizadas = urls_por_query[urls_por_query["num_urls"] > 1]
print(f"Queries con posible canibalización: {len(queries_canibalizadas)}")
Con esto ya tienes el universo de casos a investigar. En sitios medianos suelen salir entre 200 y 800 queries. En sitios grandes, varios miles.
2. Calcular el grado de dispersión
No todas las queries con varias URLs son igual de problemáticas. Una query donde el 95% de las impresiones van a una sola URL y el 5% a otra residual no es un problema real. El problema ocurre cuando las impresiones están repartidas de forma más equilibrada entre varias URLs, porque eso indica que Google duda.
Para medir esto uso la entropía de distribución de impresiones por query:
import numpy as np
def entropia(grupo):
total = grupo["impressions"].sum()
if total == 0:
return 0
proporciones = grupo["impressions"] / total
return -np.sum(proporciones * np.log2(proporciones + 1e-9))
entropia_por_query = df.groupby("query").apply(entropia).reset_index()
entropia_por_query.columns = ["query", "entropia"]
Una entropía alta significa que las impresiones están muy repartidas entre URLs. Una entropía baja significa que hay una URL claramente dominante.
3. Combinar señales y priorizar
Junto todo en un solo dataframe y añado el volumen de impresiones como variable de prioridad:
# Total de impresiones por query
impresiones_totales = df.groupby("query")["impressions"].sum().reset_index()
impresiones_totales.columns = ["query", "impressions_total"]
# Unir todo
resumen = queries_canibalizadas.merge(entropia_por_query, on="query")
resumen = resumen.merge(impresiones_totales, on="query")
# Score de prioridad: más impresiones y más dispersión = más urgente
resumen["score_canibalizacion"] = resumen["entropia"] * np.log1p(resumen["impressions_total"])
resumen = resumen.sort_values("score_canibalizacion", ascending=False)
print(resumen.head(20))
4. Añadir el detalle de URLs por query
Para poder actuar, necesito saber exactamente qué URLs están compitiendo por cada query problemática. Genero un segundo dataframe con ese nivel de detalle:
# Unir el resumen con el detalle de URLs
detalle = df.merge(resumen[["query", "score_canibalizacion"]], on="query")
detalle = detalle.sort_values(
["score_canibalizacion", "query", "impressions"],
ascending=[False, True, False]
)
detalle.to_csv("canibalizacion_detalle.csv", index=False)
resumen.to_csv("canibalizacion_resumen.csv", index=False)
El archivo de detalle es el que uso en las reuniones con el cliente. Para cada query problemática puedes ver qué URLs compiten, cuántas impresiones tiene cada una, su posición media y su CTR. Con eso encima de la mesa, la conversación sobre qué consolidar y cómo es mucho más directa.
Cómo interpreto los resultados
Una vez tengo el output, mi proceso de revisión sigue siempre el mismo orden:
Primero, miro las queries con score más alto. Son las que más tráfico potencial están perdiendo por la dispersión.
Segundo, para cada query reviso las URLs en disputa y me pregunto:
- ¿Tienen intención realmente equivalente o hay matices?
- ¿Cuál de las dos URLs es más relevante para esa query según su contenido?
- ¿Hay una URL claramente mejor posicionada que debería ser la canónica?
Tercero, clasifico cada caso en una de estas categorías:
- Consolidar: el contenido de ambas URLs es tan similar que tiene sentido fusionarlas en una sola
- Redirigir: una URL es claramente inferior y debe redirigirse a la principal con un 301
- Canonicalizar: si no se puede redirigir, usar
rel="canonical"para señalar la URL preferida - Diferenciar: el contenido tiene matices suficientes pero hay que reforzar la diferenciación semántica para que Google entienda que no compiten
La mayoría de los casos en sitios con historiales largos caen en las dos primeras categorías.
Un patrón que encuentro casi siempre
En agencia hay un patrón que se repite mucho: el blog canibalizando las páginas de servicio o producto. La empresa tiene una página de servicio optimizada para “consultoría SEO Barcelona” y también tiene tres posts del blog sobre el mismo tema. Google no sabe qué posicionar y acaba rotando entre ellas.
La solución habitual no es eliminar los posts, sino trabajar la diferenciación de intención: la página de servicio apunta a intención transaccional, los posts a intención informacional. Y reforzar el enlazado interno desde los posts hacia la página de servicio, no al revés.
Lo que el modelo no ve
Como en cualquier análisis automatizado, hay casos que el modelo no detecta bien:
- Canibalización entre subdominios o dominios distintos del mismo cliente (requiere cruzar propiedades de GSC)
- Canibalización por sinónimos si las queries no comparten texto (aquí ayuda el clustering semántico del post anterior)
- Páginas de categoría compitiendo con páginas de producto en e-commerce, que tienen su propia casuística
Para esos casos, el modelo es un punto de partida pero necesitas completarlo con análisis manual o con otras fuentes de datos.
En el siguiente post cierro esta serie de Data Science aplicado a SEO con algo que me preguntan mucho: cómo predigo el impacto de una migración SEO antes de ejecutarla. Que es, básicamente, la forma de no llegar a una reunión postmigración con malas noticias que podrías haber anticipado.