seo_gsc: cobertura por delante del CTR, y arreglar el aviso duplicado
Recalibrado contra los datos reales del primer día, que desmintieron dos supuestos del diseño. El umbral de 150 impresiones dejaba fuera TODO: el mejor caso de los dos blogs eran las 125 de Varginha en EN. El vigilante se habría callado para siempre, la peor avería en algo que solo habla cuando hay noticias. Baja a 40, y entre 40 y 150 las líneas salen marcadas con «~» porque ahí el CTR es orientativo, no firme. Y el CTR no es la palanca a este volumen: con ~180 impresiones semanales en EN, convertir el 10% de todas serían 78 clics al mes. El informe pasa a liderar con cobertura — impresiones, páginas que reciben alguna, consultas distintas, posición ponderada, y qué páginas empiezan o dejan de aparecer — y avisa también cuando eso se mueve (±25% de impresiones o ±3 páginas), no solo cuando hay CTR desperdiciado. De propina, un fallo que solo se vio ejecutando el servicio dos veces seguidas: avisaba las dos. Las listas nuevas ordenan un set de URLs por impresiones y los empates los desempataba el orden de iteración del conjunto, que Python aleatoriza por proceso; la huella cambiaba sin que cambiara un solo dato. Todas las ordenaciones llevan ya desempate por clave. Verificado: mismo texto con PYTHONHASHSEED 1, 2 y 3, y la segunda ejecución del servicio se calla.
This commit is contained in:
+41
-12
@@ -93,24 +93,53 @@ seo-gsc antes-despues --corte 2026-06-23 --dias 28
|
|||||||
|
|
||||||
## Qué mira el informe
|
## Qué mira el informe
|
||||||
|
|
||||||
- **Movimiento** de clics, impresiones y CTR contra el periodo anterior.
|
Primero **cobertura**, que es la métrica principal:
|
||||||
- **Páginas que posicionan y no se pinchan.** Es la lista que importa: si algo
|
|
||||||
está en posición 6 con 2.400 impresiones y 0,16% de CTR, no tiene un problema
|
- **Impresiones, páginas que reciben alguna y consultas distintas**, contra el
|
||||||
de posicionamiento, tiene un problema de titular. Cada línea estima los clics
|
periodo anterior, más la **posición media ponderada** (ojo al signo: subir es
|
||||||
que faltan si el CTR fuese el normal de esa posición.
|
empeorar).
|
||||||
- **Consultas con impresiones y cero clics**, que son temas donde ya salimos y
|
- **Páginas que empiezan a aparecer** y **páginas que han dejado de aparecer**.
|
||||||
no convencemos.
|
En un sitio joven eso *es* la noticia: que un artículo entre en el índice útil
|
||||||
|
importa más que dos décimas de CTR.
|
||||||
|
|
||||||
|
Después **conversión**:
|
||||||
|
|
||||||
|
- **Páginas que posicionan y no se pinchan.** Cada línea estima los clics que
|
||||||
|
faltan si el CTR fuese el normal de esa posición.
|
||||||
|
- **Consultas donde salimos y no nos pinchan.**
|
||||||
|
|
||||||
El CTR "normal" sale de una curva de referencia del sector, no de una medición
|
El CTR "normal" sale de una curva de referencia del sector, no de una medición
|
||||||
nuestra: **sirve para ordenar candidatas, no para prometer tráfico**. Solo se
|
nuestra: **sirve para ordenar candidatas, no para prometer tráfico**.
|
||||||
avisa por encima de 150 impresiones, porque por debajo el CTR es ruido.
|
|
||||||
|
### Por qué la cobertura va primero
|
||||||
|
|
||||||
|
Al estrenar la herramienta con datos reales (2026-07-28) el suelo, ya sin el
|
||||||
|
pico de mayo, era de ~180 impresiones semanales en EN y ~40 en ES, con 0-1
|
||||||
|
clics. Con esas cifras el CTR no es la palanca: **aunque convirtiéramos el 10%
|
||||||
|
de todas las impresiones de EN serían unos 78 clics al mes**. El techo lo pone
|
||||||
|
cuánta gente nos ve.
|
||||||
|
|
||||||
|
Los datos también dijeron dónde está el problema de verdad — posicionamos en el
|
||||||
|
top-10 de cadenas hiperespecíficas que no busca nadie (`341.roswell.41`) y en la
|
||||||
|
página 5-7 de los términos que la gente escribe (`betty and barney` en 55,
|
||||||
|
`roswell 1947` en 52,8). Eso es autoridad de dominio, no titulares.
|
||||||
|
|
||||||
|
### El umbral, y por qué se bajó a 40
|
||||||
|
|
||||||
|
El primer intento usaba 150 impresiones, razonable para un sitio con tráfico.
|
||||||
|
Con datos delante, **nada** lo pasaba: el mejor caso era Varginha en EN con 125.
|
||||||
|
El vigilante se habría callado para siempre, que es la peor avería posible en
|
||||||
|
algo que solo habla cuando hay noticias. Ahora el corte está en 40 y las líneas
|
||||||
|
por debajo de 150 salen marcadas con `~`: orientativas, no firmes.
|
||||||
|
|
||||||
## Automático
|
## Automático
|
||||||
|
|
||||||
`seo-gsc-watch.timer` — lunes 06:15 UTC: descarga y manda el informe por
|
`seo-gsc-watch.timer` — lunes 06:15 UTC: descarga y manda el informe por
|
||||||
Telegram, pero **solo si hay páginas desperdiciadas y el informe ha cambiado**
|
Telegram, pero **solo si hay algo que contar y el informe ha cambiado** desde el
|
||||||
desde el último aviso. Misma disciplina que `seo-watch`: si no hay nada que
|
último aviso. Misma disciplina que `seo-watch`: si no hay nada que decir, se
|
||||||
decir, se calla.
|
calla. Hay algo que contar si aparecen páginas desperdiciadas **o si la
|
||||||
|
cobertura se ha movido** — un ±25% de impresiones o ±3 páginas que reciben
|
||||||
|
alguna.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
systemctl status seo-gsc-watch.timer
|
systemctl status seo-gsc-watch.timer
|
||||||
|
|||||||
+81
-16
@@ -30,9 +30,12 @@ Subcomandos:
|
|||||||
primera vez, que es todo el historial que Google guarda).
|
primera vez, que es todo el historial que Google guarda).
|
||||||
|
|
||||||
seo_gsc.py informe [--site en|es] [--dias N] [--telegram]
|
seo_gsc.py informe [--site en|es] [--dias N] [--telegram]
|
||||||
El marcador: totales, movimiento contra el periodo anterior y la lista
|
El marcador. Primero COBERTURA (impresiones, páginas que reciben
|
||||||
de páginas que POSICIONAN BIEN Y NO SE PINCHAN, que es donde está el
|
alguna, consultas distintas, posición) y qué páginas empiezan o dejan
|
||||||
dinero sin escribir nada nuevo.
|
de aparecer; después conversión y las páginas que posicionan bien y no
|
||||||
|
se pinchan. Ese orden no es estético: medido el 2026-07-28, con ~180
|
||||||
|
impresiones semanales en EN el techo de optimizar titulares son unas
|
||||||
|
decenas de clics al mes. Lo que mueve la aguja es aparecer más.
|
||||||
|
|
||||||
seo_gsc.py antes-despues --corte AAAA-MM-DD [--dias N]
|
seo_gsc.py antes-despues --corte AAAA-MM-DD [--dias N]
|
||||||
Compara la misma ventana antes y después de una fecha. Escrito para
|
Compara la misma ventana antes y después de una fecha. Escrito para
|
||||||
@@ -78,9 +81,28 @@ SITIOS = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
RETRASO_DIAS = 3 # margen para que Google consolide
|
RETRASO_DIAS = 3 # margen para que Google consolide
|
||||||
IMPRESIONES_MIN = 150 # por debajo, el CTR es ruido estadístico
|
|
||||||
|
# ⚠️ Calibrado con datos reales el 2026-07-28, y el primer intento estaba mal.
|
||||||
|
# Con IMPRESIONES_MIN = 150 (razonable para un sitio con tráfico) NADA pasaba el
|
||||||
|
# filtro: el mejor caso de los dos blogs era la página de Varginha en EN con 125
|
||||||
|
# impresiones en 28 días. El vigilante se habría callado para siempre, que es la
|
||||||
|
# peor avería posible en algo que solo habla cuando hay noticias.
|
||||||
|
#
|
||||||
|
# 40 es el compromiso: por debajo, un solo clic mueve el CTR dos puntos y medio
|
||||||
|
# y cualquier conclusión es inventada. Entre 40 y 150 el dato es orientativo y
|
||||||
|
# el informe lo marca con «~» para que nadie lo lea como si fuese firme.
|
||||||
|
IMPRESIONES_MIN = 40
|
||||||
|
IMPRESIONES_FIRMES = 150 # a partir de aquí el CTR deja de ser orientativo
|
||||||
FRACCION_ALERTA = 0.45 # se avisa si el CTR no llega a esta parte del esperado
|
FRACCION_ALERTA = 0.45 # se avisa si el CTR no llega a esta parte del esperado
|
||||||
|
|
||||||
|
# Cuánto tiene que moverse la cobertura para merecer un aviso. A este volumen
|
||||||
|
# (~180 impresiones/semana en EN, ~40 en ES) el CTR no es la palanca: aunque
|
||||||
|
# convirtiéramos el 10% de TODAS las impresiones de EN serían 78 clics al mes.
|
||||||
|
# El techo lo pone cuánta gente nos ve, no el titular. Por eso el marcador vigila
|
||||||
|
# primero la cobertura y solo después el CTR.
|
||||||
|
UMBRAL_MOV = 0.25 # cambio relativo de impresiones que merece un aviso
|
||||||
|
UMBRAL_PAGINAS = 3 # o este salto en nº de páginas que reciben impresiones
|
||||||
|
|
||||||
# CTR esperado por posición. Es una curva de referencia del sector, no una
|
# CTR esperado por posición. Es una curva de referencia del sector, no una
|
||||||
# medición nuestra: sirve para ORDENAR candidatas, no para prometer tráfico.
|
# medición nuestra: sirve para ORDENAR candidatas, no para prometer tráfico.
|
||||||
CTR_ESPERADO = {1: .270, 2: .150, 3: .110, 4: .080, 5: .062,
|
CTR_ESPERADO = {1: .270, 2: .150, 3: .110, 4: .080, 5: .062,
|
||||||
@@ -250,6 +272,12 @@ def totales(d):
|
|||||||
return cl, im, (cl / im * 100 if im else 0.0)
|
return cl, im, (cl / im * 100 if im else 0.0)
|
||||||
|
|
||||||
|
|
||||||
|
def pos_ponderada(d):
|
||||||
|
"""Posición media del sitio, ponderada por impresiones (nunca a pelo)."""
|
||||||
|
im = sum(v["imp"] for v in d.values())
|
||||||
|
return (sum(v["pos"] * v["imp"] for v in d.values()) / im) if im else 0.0
|
||||||
|
|
||||||
|
|
||||||
def desperdiciadas(paginas):
|
def desperdiciadas(paginas):
|
||||||
"""Páginas que rankean y no se pinchan, ordenadas por clics que faltan."""
|
"""Páginas que rankean y no se pinchan, ordenadas por clics que faltan."""
|
||||||
out = []
|
out = []
|
||||||
@@ -260,7 +288,7 @@ def desperdiciadas(paginas):
|
|||||||
esp = esperado(v["pos"])
|
esp = esperado(v["pos"])
|
||||||
if ctr < esp * FRACCION_ALERTA:
|
if ctr < esp * FRACCION_ALERTA:
|
||||||
out.append((u, v, ctr, esp, (esp - ctr) * v["imp"]))
|
out.append((u, v, ctr, esp, (esp - ctr) * v["imp"]))
|
||||||
return sorted(out, key=lambda x: -x[4])
|
return sorted(out, key=lambda x: (-x[4], x[0]))
|
||||||
|
|
||||||
|
|
||||||
def linea_mov(nombre, act, ant):
|
def linea_mov(nombre, act, ant):
|
||||||
@@ -284,31 +312,68 @@ def informe_sitio(c, sitio, dias):
|
|||||||
|
|
||||||
cl, im, ctr = totales(act)
|
cl, im, ctr = totales(act)
|
||||||
cl0, im0, ctr0 = totales(ant) if ant else (None, None, None)
|
cl0, im0, ctr0 = totales(ant) if ant else (None, None, None)
|
||||||
|
|
||||||
|
# Cobertura: cuántas páginas y cuántas consultas nos ven siquiera. Es la
|
||||||
|
# métrica principal, no el CTR — ver el comentario de UMBRAL_MOV.
|
||||||
|
q_act = agrega(c, sitio, "query", desde.isoformat(), hasta.isoformat())
|
||||||
|
q_ant = agrega(c, sitio, "query", p_ant.isoformat(), p_des.isoformat())
|
||||||
|
pos, pos0 = pos_ponderada(act), pos_ponderada(ant) if ant else None
|
||||||
|
|
||||||
L = [f"═══ [{sitio.upper()}] {SITIOS[sitio]['host']} ({desde} → {hasta}, {dias} d)",
|
L = [f"═══ [{sitio.upper()}] {SITIOS[sitio]['host']} ({desde} → {hasta}, {dias} d)",
|
||||||
" " + linea_mov("clics", cl, cl0),
|
" ── cobertura: a cuánta gente llegamos ──",
|
||||||
" " + linea_mov("impresiones", im, im0),
|
" " + linea_mov("impresiones", im, im0),
|
||||||
|
" " + linea_mov("páginas con impresiones", len(act), len(ant) if ant else None),
|
||||||
|
" " + linea_mov("consultas distintas", len(q_act), len(q_ant) if q_ant else None),
|
||||||
|
# ojo al signo: aquí subir es empeorar (posición 5 → 12 es caer)
|
||||||
|
" " + linea_mov("posición media (+ es peor)", round(pos, 1),
|
||||||
|
round(pos0, 1) if pos0 else None),
|
||||||
|
" ── conversión: cuántos pinchan ──",
|
||||||
|
" " + linea_mov("clics", cl, cl0),
|
||||||
" " + linea_mov("CTR %", round(ctr, 2), round(ctr0, 2) if ctr0 is not None else None)]
|
" " + linea_mov("CTR %", round(ctr, 2), round(ctr0, 2) if ctr0 is not None else None)]
|
||||||
|
|
||||||
|
nuevas = sorted(set(act) - set(ant),
|
||||||
|
key=lambda u: (-act[u]["imp"], u))[:5] if ant else []
|
||||||
|
if nuevas:
|
||||||
|
L.append("\n ── páginas que EMPIEZAN a aparecer ──")
|
||||||
|
for u in nuevas:
|
||||||
|
L.append(f" {act[u]['imp']:5} imp pos {act[u]['pos']:4.1f} "
|
||||||
|
f"{u.rstrip('/').rsplit('/', 1)[-1][:48]}")
|
||||||
|
perdidas = sorted(set(ant) - set(act),
|
||||||
|
key=lambda u: (-ant[u]["imp"], u))[:5] if ant else []
|
||||||
|
if perdidas:
|
||||||
|
L.append("\n ── páginas que han DEJADO de aparecer ──")
|
||||||
|
for u in perdidas:
|
||||||
|
L.append(f" {ant[u]['imp']:5} imp antes {u.rstrip('/').rsplit('/', 1)[-1][:44]}")
|
||||||
|
|
||||||
mal = desperdiciadas(act)
|
mal = desperdiciadas(act)
|
||||||
if mal:
|
if mal:
|
||||||
L.append(f"\n ── posicionan y no se pinchan ({len(mal)}) ──")
|
L.append(f"\n ── posicionan y no se pinchan ({len(mal)}) ──")
|
||||||
L.append(" cada línea: clics que faltan si el CTR fuese el normal de su posición")
|
L.append(" clics que faltan si el CTR fuese el normal de su posición")
|
||||||
|
L.append(f" «~» = menos de {IMPRESIONES_FIRMES} impresiones: orientativo, no firme")
|
||||||
for u, v, ctr_r, esp, hueco in mal[:8]:
|
for u, v, ctr_r, esp, hueco in mal[:8]:
|
||||||
slug = u.rstrip("/").rsplit("/", 1)[-1][:44]
|
slug = u.rstrip("/").rsplit("/", 1)[-1][:44]
|
||||||
L.append(f" +{hueco:5.0f} clics {v['imp']:5} imp CTR {ctr_r*100:4.2f}% "
|
flojo = "~" if v["imp"] < IMPRESIONES_FIRMES else " "
|
||||||
|
L.append(f" {flojo}+{hueco:5.0f} clics {v['imp']:5} imp CTR {ctr_r*100:4.2f}% "
|
||||||
f"(normal {esp*100:4.2f}%) pos {v['pos']:4.1f} {slug}")
|
f"(normal {esp*100:4.2f}%) pos {v['pos']:4.1f} {slug}")
|
||||||
else:
|
else:
|
||||||
L.append("\n ── sin páginas desperdiciadas por encima del umbral ──")
|
L.append(f"\n ── ninguna página llega a {IMPRESIONES_MIN} impresiones ──")
|
||||||
|
|
||||||
consultas = agrega(c, sitio, "query", desde.isoformat(), hasta.isoformat())
|
# Aviso por movimiento de cobertura, no solo por CTR: en un sitio que aún no
|
||||||
sin_clic = [(k, v) for k, v in consultas.items()
|
# tiene tráfico, que aparezcan (o desaparezcan) páginas ES la noticia.
|
||||||
if v["clics"] == 0 and v["imp"] >= 30 and v["pos"] <= 15]
|
movida = False
|
||||||
|
if im0 and im0 >= 50:
|
||||||
|
movida = abs(im - im0) / im0 >= UMBRAL_MOV
|
||||||
|
if ant:
|
||||||
|
movida = movida or abs(len(act) - len(ant)) >= UMBRAL_PAGINAS
|
||||||
|
|
||||||
|
sin_clic = [(k, v) for k, v in q_act.items()
|
||||||
|
if v["clics"] == 0 and v["imp"] >= 10 and v["pos"] <= 15]
|
||||||
if sin_clic:
|
if sin_clic:
|
||||||
L.append(f"\n ── consultas con impresiones y CERO clics ({len(sin_clic)}) ──")
|
L.append(f"\n ── consultas donde salimos y no nos pinchan ({len(sin_clic)}) ──")
|
||||||
for k, v in sorted(sin_clic, key=lambda x: -x[1]["imp"])[:6]:
|
for k, v in sorted(sin_clic, key=lambda x: (-x[1]["imp"], x[0]))[:6]:
|
||||||
L.append(f" {v['imp']:5} imp pos {v['pos']:4.1f} {k[:58]}")
|
L.append(f" {v['imp']:5} imp pos {v['pos']:4.1f} {k[:58]}")
|
||||||
|
|
||||||
return "\n".join(L), bool(mal)
|
return "\n".join(L), bool(mal) or movida
|
||||||
|
|
||||||
|
|
||||||
def cmd_informe(a):
|
def cmd_informe(a):
|
||||||
@@ -375,7 +440,7 @@ def cmd_antes_despues(a):
|
|||||||
for u, va, vd in comunes:
|
for u, va, vd in comunes:
|
||||||
ca, cd = va["clics"] / va["imp"], vd["clics"] / vd["imp"]
|
ca, cd = va["clics"] / va["imp"], vd["clics"] / vd["imp"]
|
||||||
movidos.append((cd - ca, u, ca, cd, va["pos"], vd["pos"]))
|
movidos.append((cd - ca, u, ca, cd, va["pos"], vd["pos"]))
|
||||||
for delta, u, ca, cd, pa, pd in sorted(movidos, key=lambda x: -abs(x[0]))[:8]:
|
for delta, u, ca, cd, pa, pd in sorted(movidos, key=lambda x: (-abs(x[0]), x[1]))[:8]:
|
||||||
slug = u.rstrip("/").rsplit("/", 1)[-1][:40]
|
slug = u.rstrip("/").rsplit("/", 1)[-1][:40]
|
||||||
print(f" CTR {ca*100:5.2f}% → {cd*100:5.2f}% ({delta*100:+5.2f}) "
|
print(f" CTR {ca*100:5.2f}% → {cd*100:5.2f}% ({delta*100:+5.2f}) "
|
||||||
f"pos {pa:4.1f} → {pd:4.1f} {slug}")
|
f"pos {pa:4.1f} → {pd:4.1f} {slug}")
|
||||||
|
|||||||
Reference in New Issue
Block a user