diff --git a/MANUAL-GSC.md b/MANUAL-GSC.md index a27cd10..f1abc73 100644 --- a/MANUAL-GSC.md +++ b/MANUAL-GSC.md @@ -93,24 +93,53 @@ seo-gsc antes-despues --corte 2026-06-23 --dias 28 ## Qué mira el informe -- **Movimiento** de clics, impresiones y CTR contra el periodo anterior. -- **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 - de posicionamiento, tiene un problema de titular. Cada línea estima los clics - que faltan si el CTR fuese el normal de esa posición. -- **Consultas con impresiones y cero clics**, que son temas donde ya salimos y - no convencemos. +Primero **cobertura**, que es la métrica principal: + +- **Impresiones, páginas que reciben alguna y consultas distintas**, contra el + periodo anterior, más la **posición media ponderada** (ojo al signo: subir es + empeorar). +- **Páginas que empiezan a aparecer** y **páginas que han dejado de aparecer**. + 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 -nuestra: **sirve para ordenar candidatas, no para prometer tráfico**. Solo se -avisa por encima de 150 impresiones, porque por debajo el CTR es ruido. +nuestra: **sirve para ordenar candidatas, no para prometer tráfico**. + +### 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 `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** -desde el último aviso. Misma disciplina que `seo-watch`: si no hay nada que -decir, se calla. +Telegram, pero **solo si hay algo que contar y el informe ha cambiado** desde el +último aviso. Misma disciplina que `seo-watch`: si no hay nada que decir, se +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 systemctl status seo-gsc-watch.timer diff --git a/seo_gsc.py b/seo_gsc.py index c609a39..ef22403 100644 --- a/seo_gsc.py +++ b/seo_gsc.py @@ -30,9 +30,12 @@ Subcomandos: primera vez, que es todo el historial que Google guarda). seo_gsc.py informe [--site en|es] [--dias N] [--telegram] - El marcador: totales, movimiento contra el periodo anterior y la lista - de páginas que POSICIONAN BIEN Y NO SE PINCHAN, que es donde está el - dinero sin escribir nada nuevo. + El marcador. Primero COBERTURA (impresiones, páginas que reciben + alguna, consultas distintas, posición) y qué páginas empiezan o dejan + 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] 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 -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 +# 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 # medición nuestra: sirve para ORDENAR candidatas, no para prometer tráfico. 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) +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): """Páginas que rankean y no se pinchan, ordenadas por clics que faltan.""" out = [] @@ -260,7 +288,7 @@ def desperdiciadas(paginas): esp = esperado(v["pos"]) if ctr < esp * FRACCION_ALERTA: 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): @@ -284,31 +312,68 @@ def informe_sitio(c, sitio, dias): cl, im, ctr = totales(act) 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)", - " " + linea_mov("clics", cl, cl0), + " ── cobertura: a cuánta gente llegamos ──", " " + 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)] + 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) if 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]: 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}") 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()) - sin_clic = [(k, v) for k, v in consultas.items() - if v["clics"] == 0 and v["imp"] >= 30 and v["pos"] <= 15] + # Aviso por movimiento de cobertura, no solo por CTR: en un sitio que aún no + # tiene tráfico, que aparezcan (o desaparezcan) páginas ES la noticia. + 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: - L.append(f"\n ── consultas con impresiones y CERO clics ({len(sin_clic)}) ──") - for k, v in sorted(sin_clic, key=lambda x: -x[1]["imp"])[:6]: + 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"], x[0]))[:6]: 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): @@ -375,7 +440,7 @@ def cmd_antes_despues(a): for u, va, vd in comunes: ca, cd = va["clics"] / va["imp"], vd["clics"] / vd["imp"] 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] print(f" CTR {ca*100:5.2f}% → {cd*100:5.2f}% ({delta*100:+5.2f}) " f"pos {pa:4.1f} → {pd:4.1f} {slug}")