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:
ChemaVX
2026-07-27 20:36:45 +00:00
co-authored by Claude Opus 5
parent 0580357333
commit 896d723f15
2 changed files with 617 additions and 0 deletions
+122
View File
@@ -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
View File
@@ -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())