seo_gsc: el marcador — datos reales de Search Console
Toda la caja de herramientas miraba hacia dentro (enlaces, sitemap, reglas de metas): decía si el sitio está bien construido, no si alguien lo encuentra. Los únicos datos de resultado en la máquina eran una exportación manual del 2026-06-17, anterior al remate de metas del 23 de junio — o sea, cuarenta días optimizando sin saber el resultado. Cuenta de servicio en vez de OAuth, porque aquí no hay navegador (misma piedra que con Gemini). Sin librerías de Google: el JWT se firma con PyJWT y se canjea a mano, treinta líneas en lugar de media docena de dependencias. Lo que de verdad aporta es `informe`: la lista de páginas que POSICIONAN Y NO SE PINCHAN, con los clics que faltan si el CTR fuese el normal de su posición. Y `antes-despues`, escrito para responder de una vez si el remate del 23-jun sirvió: la API guarda 16 meses, así que el histórico está disponible desde el primer minuto y no hay que esperar semanas para comparar. Probado lo que se puede probar sin credencial: firma RS256 emitida y verificada con su pública, los dos caminos de fallo de la credencial (ausente y con permisos flojos), y la lógica de análisis contra datos sintéticos calcados del caso Nimitz real (pos 6,44 / 2.452 impresiones / 0,16% CTR), que sale marcada con +111 clics de hueco mientras la página sana no se marca. El timer queda instalado y PARADO: sin credencial fallaría cada lunes, y un timer que falla siempre enseña a ignorar los avisos. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
+122
@@ -0,0 +1,122 @@
|
|||||||
|
# El marcador — Search Console (`seo_gsc.py`)
|
||||||
|
|
||||||
|
Lo que faltaba en toda la caja de herramientas: **datos de resultado**. El
|
||||||
|
resto mira hacia dentro (enlaces, sitemap, reglas de metas); esto mira lo único
|
||||||
|
que decide si el trabajo sirvió — cuánta gente nos encuentra y cuánta pincha.
|
||||||
|
|
||||||
|
Es de **solo lectura**. No escribe en los blogs ni en Search Console.
|
||||||
|
|
||||||
|
## Puesta en marcha (cuatro pasos de navegador, una sola vez)
|
||||||
|
|
||||||
|
La API es gratis. Hace falta una **cuenta de servicio**, no OAuth: en esta
|
||||||
|
máquina no hay navegador y el flujo interactivo no funciona headless — la misma
|
||||||
|
piedra con la que ya tropezamos en Gemini.
|
||||||
|
|
||||||
|
1. **Proyecto y API.** En <https://console.cloud.google.com> crea un proyecto
|
||||||
|
(o reutiliza uno) y activa **Google Search Console API** en
|
||||||
|
*APIs y servicios → Biblioteca*.
|
||||||
|
|
||||||
|
2. **Cuenta de servicio.** *IAM y administración → Cuentas de servicio → Crear*.
|
||||||
|
No necesita ningún rol de IAM: el permiso que importa se da en el paso 4.
|
||||||
|
Luego *Claves → Añadir clave → Crear nueva → JSON* y descarga el fichero.
|
||||||
|
|
||||||
|
3. **La clave, en su sitio.** En esta máquina:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mkdir -p ~/.config/gsc
|
||||||
|
mv ~/Descargas/EL-FICHERO.json ~/.config/gsc/service-account.json
|
||||||
|
chmod 600 ~/.config/gsc/service-account.json
|
||||||
|
```
|
||||||
|
|
||||||
|
La herramienta **se niega a arrancar** si el fichero es legible por otros.
|
||||||
|
Está fuera de cualquier repo; no la metas en git.
|
||||||
|
|
||||||
|
4. **Permiso en Search Console.** Este es el paso que se olvida. Para **cada
|
||||||
|
una de las dos propiedades** (`theexclusionzone.com` y
|
||||||
|
`zonadeexclusion.com`): *Configuración → Usuarios y permisos → Añadir
|
||||||
|
usuario* → pega el `client_email` de la credencial (algo como
|
||||||
|
`nombre@proyecto.iam.gserviceaccount.com`) → permiso **Restringido**, que
|
||||||
|
basta para leer.
|
||||||
|
|
||||||
|
⚠️ Si `zonadeexclusion.com` no está dada de alta en Search Console, hay que
|
||||||
|
verificarla antes. Del blog ES no había ni una sola exportación, así que es
|
||||||
|
probable que ni exista la propiedad.
|
||||||
|
|
||||||
|
5. **Arrancar el vigilante.** El timer se instala **parado a propósito**: sin
|
||||||
|
credencial fallaría cada lunes, y un timer que falla siempre enseña a
|
||||||
|
ignorar los avisos. Se enciende cuando el canario pasa:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
seo-gsc comprueba && sudo systemctl enable --now seo-gsc-watch.timer
|
||||||
|
```
|
||||||
|
|
||||||
|
Comprobación:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
seo-gsc sitios # tienen que salir las dos propiedades
|
||||||
|
seo-gsc comprueba # canario: recorre el camino real eslabón por eslabón
|
||||||
|
```
|
||||||
|
|
||||||
|
Si el nombre que devuelve `sitios` no coincide con el configurado (puede ser
|
||||||
|
`sc-domain:x.com` o `https://www.x.com/`, con barra final), corrige el
|
||||||
|
diccionario `SITIOS` en `seo_gsc.py` con el nombre exacto.
|
||||||
|
|
||||||
|
## Uso diario
|
||||||
|
|
||||||
|
```bash
|
||||||
|
seo-gsc pull # descarga y acumula en SQLite (idempotente)
|
||||||
|
seo-gsc # sin argumentos = informe de 28 días
|
||||||
|
seo-gsc informe --dias 7
|
||||||
|
```
|
||||||
|
|
||||||
|
La primera descarga se trae **16 meses**, que es todo lo que Google guarda. Eso
|
||||||
|
significa que el histórico está disponible desde el minuto uno: no hay que
|
||||||
|
esperar semanas para tener con qué comparar.
|
||||||
|
|
||||||
|
### La pregunta pendiente
|
||||||
|
|
||||||
|
El remate de metas fue el **23 de junio** y los únicos datos que había eran del
|
||||||
|
17. En cuanto haya credencial:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
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.
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
## 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.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
systemctl status seo-gsc-watch.timer
|
||||||
|
journalctl -u seo-gsc-watch -n 40
|
||||||
|
```
|
||||||
|
|
||||||
|
## Notas de implementación
|
||||||
|
|
||||||
|
- **Sin librerías de Google.** El JWT se firma con PyJWT y se canjea a mano.
|
||||||
|
Son treinta líneas y evita arrastrar media docena de dependencias para tres
|
||||||
|
peticiones.
|
||||||
|
- **Los datos tardan 2-3 días en consolidarse.** Todo termina 3 días atrás y se
|
||||||
|
pide `dataState=final`; pedir "ayer" devuelve cifras que luego cambian solas.
|
||||||
|
- **La posición se pondera por impresiones.** Promediar posiciones a pelo da el
|
||||||
|
mismo peso a un día de 2 impresiones que a uno de 500.
|
||||||
|
- **La API pagina de 25.000 en 25.000** y no avisa de que quedan más filas.
|
||||||
|
- El almacén es SQLite en `~/.local/state/seo-gsc/gsc.db`; `pull` es idempotente
|
||||||
|
y resolapa un día por si el último se consolidó después.
|
||||||
+495
@@ -0,0 +1,495 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Tool H — el marcador: datos reales de Search Console para los dos blogs.
|
||||||
|
|
||||||
|
Por qué existe. El resto de la caja de herramientas mira HACIA DENTRO: enlaces
|
||||||
|
rotos, sitemap, metas que incumplen una regla, canibalización entre posts. Todo
|
||||||
|
eso dice si el sitio está bien construido, no si alguien lo encuentra ni si lo
|
||||||
|
pincha. Los únicos datos de resultado que había en la máquina eran una
|
||||||
|
exportación manual del 2026-06-17 — anterior a la optimización de metas del 23
|
||||||
|
de junio, o sea justo anterior al trabajo que pretendía mejorarlos. Cuarenta
|
||||||
|
días optimizando sin saber el resultado.
|
||||||
|
|
||||||
|
Qué NO hace: escribir en los blogs. Es de solo lectura de punta a punta.
|
||||||
|
|
||||||
|
Autenticación. Cuenta de servicio, sin OAuth: en esta máquina no hay navegador
|
||||||
|
y el flujo interactivo no funciona headless (misma piedra que con Gemini). No
|
||||||
|
usa las librerías de Google: se firma el JWT con PyJWT, se canjea por un token
|
||||||
|
y se llama al REST. Son treinta líneas y evita arrastrar media docena de
|
||||||
|
dependencias para tres peticiones.
|
||||||
|
|
||||||
|
Subcomandos:
|
||||||
|
|
||||||
|
seo_gsc.py sitios
|
||||||
|
Qué propiedades ve la cuenta de servicio. ES EL PRIMER PASO: hasta que
|
||||||
|
aparezcan aquí, el permiso en Search Console no está dado.
|
||||||
|
|
||||||
|
seo_gsc.py pull [--site en|es] [--desde F] [--hasta F]
|
||||||
|
Descarga y acumula en SQLite. Idempotente: reejecutar el mismo rango
|
||||||
|
no duplica. Sin --desde arranca donde se quedó (o 16 meses atrás la
|
||||||
|
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.
|
||||||
|
|
||||||
|
seo_gsc.py antes-despues --corte AAAA-MM-DD [--dias N]
|
||||||
|
Compara la misma ventana antes y después de una fecha. Escrito para
|
||||||
|
responder de una vez si el remate de metas del 23-jun sirvió de algo.
|
||||||
|
|
||||||
|
seo_gsc.py comprueba
|
||||||
|
Canario: recorre el camino REAL (credencial → firma → token → API) y
|
||||||
|
dice en qué eslabón exacto se rompe. No simula nada.
|
||||||
|
|
||||||
|
Gotchas codificados aquí:
|
||||||
|
* Los datos de GSC tardan 2-3 días en consolidarse: `dataState=final` y el
|
||||||
|
rango termina 3 días atrás. Pedir ayer devuelve cifras que luego cambian.
|
||||||
|
* La API pagina de 25.000 en 25.000 (startRow), y no avisa de que hay más.
|
||||||
|
* El nombre de la propiedad NO es el dominio: puede ser `sc-domain:x.com` o
|
||||||
|
`https://www.x.com/`, y con la barra final. Por eso existe `sitios`.
|
||||||
|
"""
|
||||||
|
import argparse
|
||||||
|
import datetime as dt
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import sqlite3
|
||||||
|
import sys
|
||||||
|
import urllib.parse
|
||||||
|
|
||||||
|
SCOPE = "https://www.googleapis.com/auth/webmasters.readonly"
|
||||||
|
TOKEN_URL = "https://oauth2.googleapis.com/token"
|
||||||
|
API = "https://searchconsole.googleapis.com/webmasters/v3"
|
||||||
|
|
||||||
|
CRED = os.path.expanduser(os.environ.get("GSC_CRED", "~/.config/gsc/service-account.json"))
|
||||||
|
BD = os.path.expanduser(os.environ.get("GSC_DB", "~/.local/state/seo-gsc/gsc.db"))
|
||||||
|
TG_SECRET = ("monitoring", "grafana-telegram-infisical")
|
||||||
|
|
||||||
|
# La propiedad se confirma con `sitios`; esto es solo el intento por defecto.
|
||||||
|
SITIOS = {
|
||||||
|
"en": {"host": "theexclusionzone.com", "propiedad": "sc-domain:theexclusionzone.com"},
|
||||||
|
"es": {"host": "zonadeexclusion.com", "propiedad": "sc-domain:zonadeexclusion.com"},
|
||||||
|
}
|
||||||
|
|
||||||
|
RETRASO_DIAS = 3 # margen para que Google consolide
|
||||||
|
IMPRESIONES_MIN = 150 # por debajo, el CTR es ruido estadístico
|
||||||
|
FRACCION_ALERTA = 0.45 # se avisa si el CTR no llega a esta parte del esperado
|
||||||
|
|
||||||
|
# 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,
|
||||||
|
6: .050, 7: .042, 8: .035, 9: .030, 10: .026}
|
||||||
|
|
||||||
|
|
||||||
|
# ─────────────────────────────── credencial ───────────────────────────────
|
||||||
|
|
||||||
|
def carga_credencial():
|
||||||
|
if not os.path.exists(CRED):
|
||||||
|
sys.exit(
|
||||||
|
f"✗ No encuentro la credencial en {CRED}\n"
|
||||||
|
" Es una clave JSON de cuenta de servicio. Ver la cabecera de\n"
|
||||||
|
" MANUAL-GSC.md para los cuatro pasos de navegador que hacen falta."
|
||||||
|
)
|
||||||
|
if os.stat(CRED).st_mode & 0o077:
|
||||||
|
sys.exit(f"✗ {CRED} es legible por otros. `chmod 600` y repite.")
|
||||||
|
with open(CRED) as f:
|
||||||
|
sa = json.load(f)
|
||||||
|
for k in ("client_email", "private_key", "token_uri"):
|
||||||
|
if k not in sa:
|
||||||
|
sys.exit(f"✗ La credencial no parece de cuenta de servicio: falta '{k}'")
|
||||||
|
return sa
|
||||||
|
|
||||||
|
|
||||||
|
def token(sa):
|
||||||
|
"""Firma el JWT y lo canjea por un access token."""
|
||||||
|
import jwt as pyjwt
|
||||||
|
import requests
|
||||||
|
ahora = int(dt.datetime.now(dt.timezone.utc).timestamp())
|
||||||
|
afirmacion = pyjwt.encode(
|
||||||
|
{"iss": sa["client_email"], "scope": SCOPE, "aud": TOKEN_URL,
|
||||||
|
"iat": ahora, "exp": ahora + 3600},
|
||||||
|
sa["private_key"], algorithm="RS256",
|
||||||
|
)
|
||||||
|
r = requests.post(TOKEN_URL, timeout=30, data={
|
||||||
|
"grant_type": "urn:ietf:params:oauth:grant-type:jwt-bearer",
|
||||||
|
"assertion": afirmacion,
|
||||||
|
})
|
||||||
|
if r.status_code != 200:
|
||||||
|
sys.exit(f"✗ Google rechazó la credencial ({r.status_code}): {r.text[:300]}")
|
||||||
|
return r.json()["access_token"]
|
||||||
|
|
||||||
|
|
||||||
|
def api(tok, ruta, cuerpo=None):
|
||||||
|
import requests
|
||||||
|
url = f"{API}/{ruta}"
|
||||||
|
if cuerpo is None:
|
||||||
|
r = requests.get(url, headers={"Authorization": f"Bearer {tok}"}, timeout=60)
|
||||||
|
else:
|
||||||
|
r = requests.post(url, headers={"Authorization": f"Bearer {tok}"},
|
||||||
|
json=cuerpo, timeout=120)
|
||||||
|
if r.status_code == 403:
|
||||||
|
sys.exit("✗ 403: la cuenta de servicio no tiene permiso sobre esa propiedad.\n"
|
||||||
|
" En Search Console → Configuración → Usuarios y permisos,\n"
|
||||||
|
" añade el client_email de la credencial como usuario.")
|
||||||
|
if r.status_code != 200:
|
||||||
|
sys.exit(f"✗ API {r.status_code}: {r.text[:300]}")
|
||||||
|
return r.json()
|
||||||
|
|
||||||
|
|
||||||
|
# ──────────────────────────────── almacén ────────────────────────────────
|
||||||
|
|
||||||
|
ESQUEMA = """
|
||||||
|
CREATE TABLE IF NOT EXISTS filas (
|
||||||
|
sitio TEXT NOT NULL, dim TEXT NOT NULL, fecha TEXT NOT NULL, clave TEXT NOT NULL,
|
||||||
|
clics INTEGER NOT NULL, impresiones INTEGER NOT NULL, posicion REAL NOT NULL,
|
||||||
|
PRIMARY KEY (sitio, dim, fecha, clave));
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_filas_fecha ON filas(sitio, dim, fecha);
|
||||||
|
CREATE TABLE IF NOT EXISTS descargas (
|
||||||
|
sitio TEXT, dim TEXT, hasta TEXT, cuando TEXT, filas INTEGER);
|
||||||
|
CREATE TABLE IF NOT EXISTS avisos (clave TEXT PRIMARY KEY, huella TEXT, cuando TEXT);
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def bd():
|
||||||
|
os.makedirs(os.path.dirname(BD), exist_ok=True)
|
||||||
|
c = sqlite3.connect(BD)
|
||||||
|
c.executescript(ESQUEMA)
|
||||||
|
return c
|
||||||
|
|
||||||
|
|
||||||
|
def ultima_fecha(c, sitio, dim):
|
||||||
|
r = c.execute("SELECT max(fecha) FROM filas WHERE sitio=? AND dim=?",
|
||||||
|
(sitio, dim)).fetchone()
|
||||||
|
return r[0] if r and r[0] else None
|
||||||
|
|
||||||
|
|
||||||
|
# ───────────────────────────────── descarga ─────────────────────────────────
|
||||||
|
|
||||||
|
def descarga(tok, propiedad, inicio, fin, dims):
|
||||||
|
"""Todas las filas del rango, paginando. La API no dice cuántas hay."""
|
||||||
|
ruta = f"sites/{urllib.parse.quote(propiedad, safe='')}/searchAnalytics/query"
|
||||||
|
filas, desde = [], 0
|
||||||
|
while True:
|
||||||
|
d = api(tok, ruta, {
|
||||||
|
"startDate": inicio, "endDate": fin, "dimensions": dims,
|
||||||
|
"rowLimit": 25000, "startRow": desde, "dataState": "final",
|
||||||
|
})
|
||||||
|
lote = d.get("rows", [])
|
||||||
|
filas.extend(lote)
|
||||||
|
if len(lote) < 25000:
|
||||||
|
return filas
|
||||||
|
desde += len(lote)
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_pull(a):
|
||||||
|
sa = carga_credencial()
|
||||||
|
tok = token(sa)
|
||||||
|
c = bd()
|
||||||
|
hasta = a.hasta or (dt.date.today() - dt.timedelta(days=RETRASO_DIAS)).isoformat()
|
||||||
|
|
||||||
|
for sitio in (["en", "es"] if a.site == "todos" else [a.site]):
|
||||||
|
prop = SITIOS[sitio]["propiedad"]
|
||||||
|
for dim in ("page", "query"):
|
||||||
|
desde = a.desde or ultima_fecha(c, sitio, dim)
|
||||||
|
if desde:
|
||||||
|
# se resolapa un día: el último descargado pudo consolidarse después
|
||||||
|
desde = (dt.date.fromisoformat(desde) - dt.timedelta(days=1)).isoformat()
|
||||||
|
else:
|
||||||
|
desde = (dt.date.today() - dt.timedelta(days=480)).isoformat()
|
||||||
|
if desde > hasta:
|
||||||
|
print(f" [{sitio}/{dim}] al día (hasta {hasta})")
|
||||||
|
continue
|
||||||
|
filas = descarga(tok, prop, desde, hasta, ["date", dim])
|
||||||
|
c.executemany(
|
||||||
|
"INSERT OR REPLACE INTO filas VALUES (?,?,?,?,?,?,?)",
|
||||||
|
[(sitio, dim, f["keys"][0], f["keys"][1],
|
||||||
|
int(f["clicks"]), int(f["impressions"]), float(f["position"]))
|
||||||
|
for f in filas])
|
||||||
|
c.execute("INSERT INTO descargas VALUES (?,?,?,?,?)",
|
||||||
|
(sitio, dim, hasta, dt.datetime.now().isoformat(timespec="seconds"),
|
||||||
|
len(filas)))
|
||||||
|
c.commit()
|
||||||
|
print(f" [{sitio}/{dim}] {len(filas)} filas {desde} → {hasta}")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
# ───────────────────────────────── informe ─────────────────────────────────
|
||||||
|
|
||||||
|
def esperado(pos):
|
||||||
|
if pos <= 1:
|
||||||
|
return CTR_ESPERADO[1]
|
||||||
|
if pos >= 10:
|
||||||
|
# más allá del 10 la curva se aplana; extrapolar promete lo que no hay
|
||||||
|
return CTR_ESPERADO[10]
|
||||||
|
bajo, alto = int(pos), int(pos) + 1
|
||||||
|
t = pos - bajo
|
||||||
|
return CTR_ESPERADO[bajo] + (CTR_ESPERADO[alto] - CTR_ESPERADO[bajo]) * t
|
||||||
|
|
||||||
|
|
||||||
|
def agrega(c, sitio, dim, desde, hasta):
|
||||||
|
"""Suma por clave en la ventana. La posición se pondera por impresiones:
|
||||||
|
promediar posiciones a pelo da igual peso a un día de 2 impresiones que a
|
||||||
|
uno de 500, y el número sale mentiroso."""
|
||||||
|
filas = c.execute(
|
||||||
|
"SELECT clave, sum(clics), sum(impresiones), "
|
||||||
|
" sum(posicion*impresiones)/nullif(sum(impresiones),0) "
|
||||||
|
"FROM filas WHERE sitio=? AND dim=? AND fecha BETWEEN ? AND ? "
|
||||||
|
"GROUP BY clave", (sitio, dim, desde, hasta)).fetchall()
|
||||||
|
return {k: {"clics": cl, "imp": im, "pos": po or 0.0} for k, cl, im, po in filas}
|
||||||
|
|
||||||
|
|
||||||
|
def totales(d):
|
||||||
|
cl = sum(v["clics"] for v in d.values())
|
||||||
|
im = sum(v["imp"] for v in d.values())
|
||||||
|
return cl, im, (cl / im * 100 if im else 0.0)
|
||||||
|
|
||||||
|
|
||||||
|
def desperdiciadas(paginas):
|
||||||
|
"""Páginas que rankean y no se pinchan, ordenadas por clics que faltan."""
|
||||||
|
out = []
|
||||||
|
for u, v in paginas.items():
|
||||||
|
if v["imp"] < IMPRESIONES_MIN or v["pos"] > 10.5:
|
||||||
|
continue
|
||||||
|
ctr = v["clics"] / v["imp"]
|
||||||
|
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])
|
||||||
|
|
||||||
|
|
||||||
|
def linea_mov(nombre, act, ant):
|
||||||
|
if ant is None:
|
||||||
|
return f"{nombre}: {act}"
|
||||||
|
d = act - ant
|
||||||
|
if isinstance(act, float):
|
||||||
|
return f"{nombre}: {act:.2f} ({d:+.2f})"
|
||||||
|
return f"{nombre}: {act} ({d:+d})"
|
||||||
|
|
||||||
|
|
||||||
|
def informe_sitio(c, sitio, dias):
|
||||||
|
hasta = dt.date.today() - dt.timedelta(days=RETRASO_DIAS)
|
||||||
|
desde = hasta - dt.timedelta(days=dias - 1)
|
||||||
|
p_ant, p_des = desde - dt.timedelta(days=dias), desde - dt.timedelta(days=1)
|
||||||
|
|
||||||
|
act = agrega(c, sitio, "page", desde.isoformat(), hasta.isoformat())
|
||||||
|
ant = agrega(c, sitio, "page", p_ant.isoformat(), p_des.isoformat())
|
||||||
|
if not act:
|
||||||
|
return f"[{sitio.upper()}] sin datos en la ventana {desde}…{hasta}", False
|
||||||
|
|
||||||
|
cl, im, ctr = totales(act)
|
||||||
|
cl0, im0, ctr0 = totales(ant) if ant else (None, None, None)
|
||||||
|
L = [f"═══ [{sitio.upper()}] {SITIOS[sitio]['host']} ({desde} → {hasta}, {dias} d)",
|
||||||
|
" " + linea_mov("clics", cl, cl0),
|
||||||
|
" " + linea_mov("impresiones", im, im0),
|
||||||
|
" " + linea_mov("CTR %", round(ctr, 2), round(ctr0, 2) if ctr0 is not None else None)]
|
||||||
|
|
||||||
|
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")
|
||||||
|
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}% "
|
||||||
|
f"(normal {esp*100:4.2f}%) pos {v['pos']:4.1f} {slug}")
|
||||||
|
else:
|
||||||
|
L.append("\n ── sin páginas desperdiciadas por encima del umbral ──")
|
||||||
|
|
||||||
|
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]
|
||||||
|
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" {v['imp']:5} imp pos {v['pos']:4.1f} {k[:58]}")
|
||||||
|
|
||||||
|
return "\n".join(L), bool(mal)
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_informe(a):
|
||||||
|
c = bd()
|
||||||
|
partes, hallazgos = [], False
|
||||||
|
for sitio in (["en", "es"] if a.site == "todos" else [a.site]):
|
||||||
|
t, h = informe_sitio(c, sitio, a.dias)
|
||||||
|
partes.append(t)
|
||||||
|
hallazgos = hallazgos or h
|
||||||
|
texto = "\n\n".join(partes)
|
||||||
|
print(texto)
|
||||||
|
|
||||||
|
if a.telegram:
|
||||||
|
import hashlib
|
||||||
|
huella = hashlib.sha256(texto.encode()).hexdigest()[:16]
|
||||||
|
prev = c.execute("SELECT huella FROM avisos WHERE clave='informe'").fetchone()
|
||||||
|
if not hallazgos:
|
||||||
|
print("\n(sin hallazgos: no aviso)")
|
||||||
|
elif prev and prev[0] == huella:
|
||||||
|
print("\n(mismo informe que la última vez: no aviso)")
|
||||||
|
else:
|
||||||
|
if telegram("📊 Marcador SEO\n\n" + texto):
|
||||||
|
c.execute("INSERT OR REPLACE INTO avisos VALUES ('informe',?,?)",
|
||||||
|
(huella, dt.datetime.now().isoformat(timespec="seconds")))
|
||||||
|
c.commit()
|
||||||
|
print("\n(avisado por Telegram)")
|
||||||
|
else:
|
||||||
|
print("\n✗ Telegram no aceptó el mensaje")
|
||||||
|
return 1
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
# ─────────────────────────────── antes/después ───────────────────────────────
|
||||||
|
|
||||||
|
def cmd_antes_despues(a):
|
||||||
|
c = bd()
|
||||||
|
corte = dt.date.fromisoformat(a.corte)
|
||||||
|
a0, a1 = corte - dt.timedelta(days=a.dias), corte - dt.timedelta(days=1)
|
||||||
|
d0 = corte
|
||||||
|
d1 = min(corte + dt.timedelta(days=a.dias - 1),
|
||||||
|
dt.date.today() - dt.timedelta(days=RETRASO_DIAS))
|
||||||
|
dias_reales = (d1 - d0).days + 1
|
||||||
|
|
||||||
|
for sitio in (["en", "es"] if a.site == "todos" else [a.site]):
|
||||||
|
antes = agrega(c, sitio, "page", a0.isoformat(), a1.isoformat())
|
||||||
|
desp = agrega(c, sitio, "page", d0.isoformat(), d1.isoformat())
|
||||||
|
print(f"\n═══ [{sitio.upper()}] corte {corte}")
|
||||||
|
if not antes or not desp:
|
||||||
|
print(" sin datos suficientes a un lado del corte "
|
||||||
|
f"(antes: {len(antes)} páginas, después: {len(desp)})")
|
||||||
|
continue
|
||||||
|
if dias_reales != a.dias:
|
||||||
|
print(f" ⚠ ventanas DESIGUALES: {a.dias} d antes vs {dias_reales} d después."
|
||||||
|
"\n Los totales no son comparables; mira el CTR y la posición.")
|
||||||
|
for nombre, f in (("clics", lambda t: t[0]), ("impresiones", lambda t: t[1]),
|
||||||
|
("CTR %", lambda t: round(t[2], 2))):
|
||||||
|
va, vd = f(totales(antes)), f(totales(desp))
|
||||||
|
print(f" {nombre:12} {va:>8} → {vd:>8}")
|
||||||
|
|
||||||
|
print("\n ── páginas que más movieron su CTR ──")
|
||||||
|
comunes = [(u, antes[u], desp[u]) for u in desp if u in antes
|
||||||
|
and antes[u]["imp"] >= 50 and desp[u]["imp"] >= 50]
|
||||||
|
movidos = []
|
||||||
|
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]:
|
||||||
|
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}")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
# ──────────────────────────────── utilidades ────────────────────────────────
|
||||||
|
|
||||||
|
def cmd_sitios(a):
|
||||||
|
sa = carga_credencial()
|
||||||
|
print(f"cuenta de servicio: {sa['client_email']}")
|
||||||
|
d = api(token(sa), "sites")
|
||||||
|
sitios = d.get("siteEntry", [])
|
||||||
|
if not sitios:
|
||||||
|
print("\n✗ La cuenta de servicio no ve NINGUNA propiedad.\n"
|
||||||
|
" Falta darle permiso en Search Console (Configuración →\n"
|
||||||
|
" Usuarios y permisos → Añadir usuario → el correo de arriba).")
|
||||||
|
return 1
|
||||||
|
print(f"\n{len(sitios)} propiedad(es) visibles:")
|
||||||
|
conocidas = {v["propiedad"] for v in SITIOS.values()}
|
||||||
|
for s in sitios:
|
||||||
|
marca = " ← configurada" if s["siteUrl"] in conocidas else ""
|
||||||
|
print(f" {s['permissionLevel']:22} {s['siteUrl']}{marca}")
|
||||||
|
faltan = conocidas - {s["siteUrl"] for s in sitios}
|
||||||
|
if faltan:
|
||||||
|
print("\n⚠ configuradas en SITIOS pero NO visibles: " + ", ".join(sorted(faltan)))
|
||||||
|
print(" Corrige SITIOS con el nombre exacto de arriba, o da el permiso.")
|
||||||
|
return 1
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def telegram(texto):
|
||||||
|
import base64
|
||||||
|
import subprocess
|
||||||
|
import urllib.request
|
||||||
|
|
||||||
|
def secreto(clave):
|
||||||
|
r = subprocess.run(
|
||||||
|
f"kubectl get secret -n {TG_SECRET[0]} {TG_SECRET[1]} "
|
||||||
|
f"-o jsonpath='{{.data.{clave}}}'", shell=True, capture_output=True, text=True)
|
||||||
|
return base64.b64decode(r.stdout).decode()
|
||||||
|
|
||||||
|
datos = urllib.parse.urlencode(
|
||||||
|
{"chat_id": secreto("TELEGRAM_CHAT_ID"), "text": texto[:3900]}).encode()
|
||||||
|
req = urllib.request.Request(
|
||||||
|
f"https://api.telegram.org/bot{secreto('TELEGRAM_BOT_TOKEN')}/sendMessage", data=datos)
|
||||||
|
with urllib.request.urlopen(req, timeout=30) as resp:
|
||||||
|
return json.loads(resp.read()).get("ok", False)
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_comprueba(a):
|
||||||
|
"""Canario: el camino real, eslabón por eslabón, sin simular nada."""
|
||||||
|
print("1. credencial en disco")
|
||||||
|
sa = carga_credencial()
|
||||||
|
print(f" ✓ {CRED} (600) — {sa['client_email']}")
|
||||||
|
|
||||||
|
print("2. firma del JWT y canje por token")
|
||||||
|
tok = token(sa)
|
||||||
|
print(f" ✓ token de {len(tok)} caracteres")
|
||||||
|
|
||||||
|
print("3. la API responde y la cuenta ve propiedades")
|
||||||
|
sitios = [s["siteUrl"] for s in api(tok, "sites").get("siteEntry", [])]
|
||||||
|
if not sitios:
|
||||||
|
sys.exit(" ✗ cero propiedades visibles: falta el permiso en Search Console")
|
||||||
|
print(f" ✓ {len(sitios)}: {', '.join(sitios)}")
|
||||||
|
|
||||||
|
print("4. cada sitio configurado devuelve datos")
|
||||||
|
fallos = 0
|
||||||
|
for k, v in SITIOS.items():
|
||||||
|
if v["propiedad"] not in sitios:
|
||||||
|
print(f" ✗ [{k}] {v['propiedad']} no visible")
|
||||||
|
fallos += 1
|
||||||
|
continue
|
||||||
|
hasta = (dt.date.today() - dt.timedelta(days=RETRASO_DIAS)).isoformat()
|
||||||
|
desde = (dt.date.today() - dt.timedelta(days=33)).isoformat()
|
||||||
|
filas = descarga(tok, v["propiedad"], desde, hasta, ["date"])
|
||||||
|
imp = sum(int(f["impressions"]) for f in filas)
|
||||||
|
print(f" {'✓' if filas else '✗'} [{k}] {len(filas)} días, {imp} impresiones")
|
||||||
|
fallos += 0 if filas else 1
|
||||||
|
|
||||||
|
print("5. almacén")
|
||||||
|
c = bd()
|
||||||
|
n = c.execute("SELECT count(*) FROM filas").fetchone()[0]
|
||||||
|
print(f" ✓ {BD} — {n} filas acumuladas")
|
||||||
|
|
||||||
|
print("\n" + ("✗ hay eslabones rotos" if fallos else "✓ camino completo"))
|
||||||
|
return 1 if fallos else 0
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
ap = argparse.ArgumentParser(description="Marcador de Search Console (solo lectura)")
|
||||||
|
sub = ap.add_subparsers(dest="cmd", required=True)
|
||||||
|
|
||||||
|
p = sub.add_parser("sitios", help="propiedades visibles para la cuenta de servicio")
|
||||||
|
p.set_defaults(func=cmd_sitios)
|
||||||
|
|
||||||
|
p = sub.add_parser("pull", help="descarga y acumula en SQLite")
|
||||||
|
p.add_argument("--site", choices=("en", "es", "todos"), default="todos")
|
||||||
|
p.add_argument("--desde")
|
||||||
|
p.add_argument("--hasta")
|
||||||
|
p.set_defaults(func=cmd_pull)
|
||||||
|
|
||||||
|
p = sub.add_parser("informe", help="el marcador")
|
||||||
|
p.add_argument("--site", choices=("en", "es", "todos"), default="todos")
|
||||||
|
p.add_argument("--dias", type=int, default=28)
|
||||||
|
p.add_argument("--telegram", action="store_true")
|
||||||
|
p.set_defaults(func=cmd_informe)
|
||||||
|
|
||||||
|
p = sub.add_parser("antes-despues", help="misma ventana a los dos lados de una fecha")
|
||||||
|
p.add_argument("--corte", required=True)
|
||||||
|
p.add_argument("--dias", type=int, default=28)
|
||||||
|
p.add_argument("--site", choices=("en", "es", "todos"), default="todos")
|
||||||
|
p.set_defaults(func=cmd_antes_despues)
|
||||||
|
|
||||||
|
p = sub.add_parser("comprueba", help="canario del camino real")
|
||||||
|
p.set_defaults(func=cmd_comprueba)
|
||||||
|
|
||||||
|
a = ap.parse_args()
|
||||||
|
return a.func(a) or 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
Reference in New Issue
Block a user