gsc: página × consulta, y lo que enseña es que el CTR no era el problema

`page` y `query` se descargaban por separado, así que no había forma de saber
de qué búsquedas salían las impresiones de una URL. Con eso faltando, el dato
más llamativo del marcador (uss-nimitz: 1.254 impresiones en posición 6,7 y
CERO clics) admitía tres explicaciones incompatibles y no se podía elegir.

Ya se puede. El 91% de las impresiones del EN son ANÓNIMAS: consultas tan raras
que Google no las nombra. En el Nimitz, de 1.254 impresiones solo 29 tienen
consulta conocida. No es un problema de titular — reescribirlo no tocaría el
99% del volumen. En el ES el anónimo es el 66%, y el ES convierte 3x mejor.

Decisiones:
  - tabla filas_pq aparte, no un `dim` más de `filas`: son DOS dimensiones y
    con la clave concatenada cualquier agregado que olvidase filtrar por dim
    contaría el mismo clic dos veces
  - backfill troceado en ventanas de 90 días: con tres dimensiones las filas
    se multiplican y un tirón de 16 meses puede pasarse del tope de paginación
  - el informe enseña el HUECO lo primero, no lo esconde. Una página cuyo
    volumen es casi todo anónimo no se arregla por el titular, y el informe lo
    dice en voz alta al pasar del 70%

Verificado: filas_pq suma exactamente lo mismo que dim='query' en la misma
ventana (EN 405 imp, ES 144), que es la comprobación de que no se cuelan ni se
pierden filas por el camino.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
ChemaVX
2026-07-29 16:09:25 +00:00
co-authored by Claude Opus 5
parent f4121d11bb
commit 66540c12e6
+153
View File
@@ -29,6 +29,17 @@ Subcomandos:
no duplica. Sin --desde arranca donde se quedó (o 16 meses atrás la no duplica. Sin --desde arranca donde se quedó (o 16 meses atrás la
primera vez, que es todo el historial que Google guarda). primera vez, que es todo el historial que Google guarda).
seo_gsc.py consultas [--site en|es] [--pagina SLUG] [--dias N] [--min N]
Página × consulta: de qué búsquedas salen las impresiones de cada URL.
Responde la pregunta que las otras vistas NO pueden responder, porque
`page` y `query` por separado no se cruzan: si una página tiene 2.449
impresiones en posición 6 y cero clics, esto dice si es que sale en
consultas sin intención de clic o es que el titular no vende.
Lo primero que imprime es el HUECO: impresiones de la página menos las
que Google atribuye a alguna consulta. Ver el gotcha de la anonimización
más abajo — ese hueco no es un fallo, es el dato.
seo_gsc.py informe [--site en|es] [--dias N] [--telegram] seo_gsc.py informe [--site en|es] [--dias N] [--telegram]
El marcador. Primero COBERTURA (impresiones, páginas que reciben El marcador. Primero COBERTURA (impresiones, páginas que reciben
alguna, consultas distintas, posición) y qué páginas empiezan o dejan alguna, consultas distintas, posición) y qué páginas empiezan o dejan
@@ -54,6 +65,13 @@ Gotchas codificados aquí:
* Los datos de GSC tardan 2-3 días en consolidarse: `dataState=final` y el * 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. 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. * La API pagina de 25.000 en 25.000 (startRow), y no avisa de que hay más.
* ⚠️ Google ANONIMIZA las consultas poco frecuentes: no las devuelve en
ninguna vista que incluya la dimensión `query`. Consecuencia práctica: la
suma por `page` NO cuadra con la suma por `page`×`query`, y la diferencia
puede ser la mayor parte. No es un error de descarga ni de la ventana; es
volumen de cola larga que Google se niega a nombrar. Por eso `consultas`
enseña el hueco en primer lugar en vez de esconderlo: una página cuyas
impresiones son casi todas anónimas no tiene un problema de titular.
* El nombre de la propiedad NO es el dominio: puede ser `sc-domain:x.com` o * 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`. `https://www.x.com/`, y con la barra final. Por eso existe `sitios`.
""" """
@@ -177,6 +195,15 @@ CREATE TABLE IF NOT EXISTS filas (
clics INTEGER NOT NULL, impresiones INTEGER NOT NULL, posicion REAL NOT NULL, clics INTEGER NOT NULL, impresiones INTEGER NOT NULL, posicion REAL NOT NULL,
PRIMARY KEY (sitio, dim, fecha, clave)); PRIMARY KEY (sitio, dim, fecha, clave));
CREATE INDEX IF NOT EXISTS idx_filas_fecha ON filas(sitio, dim, fecha); CREATE INDEX IF NOT EXISTS idx_filas_fecha ON filas(sitio, dim, fecha);
-- Página × consulta. Tabla aparte y no un `dim` más de `filas` a propósito:
-- son DOS dimensiones, y meterlas ahí con la clave concatenada haría que
-- cualquier agregado que olvidase filtrar por dim contase el mismo clic dos
-- veces. Separadas, ese error es imposible.
CREATE TABLE IF NOT EXISTS filas_pq (
sitio TEXT NOT NULL, fecha TEXT NOT NULL, pagina TEXT NOT NULL, consulta TEXT NOT NULL,
clics INTEGER NOT NULL, impresiones INTEGER NOT NULL, posicion REAL NOT NULL,
PRIMARY KEY (sitio, fecha, pagina, consulta));
CREATE INDEX IF NOT EXISTS idx_pq_fecha ON filas_pq(sitio, fecha);
CREATE TABLE IF NOT EXISTS descargas ( CREATE TABLE IF NOT EXISTS descargas (
sitio TEXT, dim TEXT, hasta TEXT, cuando TEXT, filas INTEGER); sitio TEXT, dim TEXT, hasta TEXT, cuando TEXT, filas INTEGER);
CREATE TABLE IF NOT EXISTS avisos (clave TEXT PRIMARY KEY, huella TEXT, cuando TEXT); CREATE TABLE IF NOT EXISTS avisos (clave TEXT PRIMARY KEY, huella TEXT, cuando TEXT);
@@ -196,6 +223,11 @@ def ultima_fecha(c, sitio, dim):
return r[0] if r and r[0] else None return r[0] if r and r[0] else None
def ultima_fecha_pq(c, sitio):
r = c.execute("SELECT max(fecha) FROM filas_pq WHERE sitio=?", (sitio,)).fetchone()
return r[0] if r and r[0] else None
# ───────────────────────────────── descarga ───────────────────────────────── # ───────────────────────────────── descarga ─────────────────────────────────
def descarga(tok, propiedad, inicio, fin, dims): def descarga(tok, propiedad, inicio, fin, dims):
@@ -243,6 +275,40 @@ def cmd_pull(a):
len(filas))) len(filas)))
c.commit() c.commit()
print(f" [{sitio}/{dim}] {len(filas)} filas {desde}{hasta}") print(f" [{sitio}/{dim}] {len(filas)} filas {desde}{hasta}")
# ── página × consulta ──
# Se trocea en ventanas de 90 días. Con tres dimensiones las filas se
# multiplican y un backfill de 16 meses de un tirón puede pasarse del
# tope de paginación de la API; troceado no depende de eso, y además va
# informando en vez de quedarse mudo cinco minutos.
desde = a.desde or ultima_fecha_pq(c, sitio)
if desde:
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}/page×query] al día (hasta {hasta})")
continue
d0, dfin, total = dt.date.fromisoformat(desde), dt.date.fromisoformat(hasta), 0
while d0 <= dfin:
d1 = min(d0 + dt.timedelta(days=89), dfin)
filas = descarga(tok, prop, d0.isoformat(), d1.isoformat(),
["date", "page", "query"])
c.executemany(
"INSERT OR REPLACE INTO filas_pq VALUES (?,?,?,?,?,?,?)",
[(sitio, f["keys"][0], f["keys"][1], f["keys"][2],
int(f["clicks"]), int(f["impressions"]), float(f["position"]))
for f in filas])
c.commit()
total += len(filas)
if d1 < dfin:
print(f"{d0}{d1}: {len(filas)} filas")
d0 = d1 + dt.timedelta(days=1)
c.execute("INSERT INTO descargas VALUES (?,?,?,?,?)",
(sitio, "page+query", hasta,
dt.datetime.now().isoformat(timespec="seconds"), total))
c.commit()
print(f" [{sitio}/page×query] {total} filas {desde}{hasta}")
return 0 return 0
@@ -381,6 +447,85 @@ def informe_sitio(c, sitio, dias):
return "\n".join(L), bool(mal) or movida return "\n".join(L), bool(mal) or movida
# ─────────────────────────── página × consulta ───────────────────────────
def agrega_pq(c, sitio, desde, hasta, filtro=None):
"""[(pagina, consulta, clics, imp, pos)] sumado en la ventana."""
sql = ("SELECT pagina, consulta, sum(clics), sum(impresiones), "
" sum(posicion*impresiones)/nullif(sum(impresiones),0) "
"FROM filas_pq WHERE sitio=? AND fecha BETWEEN ? AND ? ")
par = [sitio, desde, hasta]
if filtro:
sql += "AND pagina LIKE ? "
par.append(f"%{filtro}%")
return c.execute(sql + "GROUP BY pagina, consulta", par).fetchall()
def consultas_sitio(c, sitio, dias, filtro, minimo):
hasta = dt.date.today() - dt.timedelta(days=RETRASO_DIAS)
desde = hasta - dt.timedelta(days=dias - 1)
filas = agrega_pq(c, sitio, desde.isoformat(), hasta.isoformat(), filtro)
paginas = agrega(c, sitio, "page", desde.isoformat(), hasta.isoformat())
if filtro:
paginas = {u: v for u, v in paginas.items() if filtro in u}
L = [f"═══ [{sitio.upper()}] {SITIOS[sitio]['host']} ({desde}{hasta}, {dias} d)"]
if not paginas:
return "\n".join(L + [" sin datos de páginas en la ventana"])
if not filas:
return "\n".join(L + [
" ⚠ no hay datos de página×consulta en esta ventana.",
" Si nunca has hecho `pull` desde que existe esta vista, hazlo:",
" la tabla filas_pq se rellena aparte y arranca vacía."])
# Por página: qué parte de sus impresiones tiene nombre de consulta.
por_pag = {}
for pag, cons, cl, im, pos in filas:
d = por_pag.setdefault(pag, {"clics": 0, "imp": 0, "q": []})
d["clics"] += cl
d["imp"] += im
d["q"].append((cons, cl, im, pos or 0.0))
im_tot = sum(v["imp"] for v in paginas.values())
im_nom = sum(v["imp"] for v in por_pag.values())
anon = im_tot - im_nom
L += [" ── de dónde salen las impresiones ──",
f" impresiones de las páginas {im_tot:7}",
f" atribuidas a una consulta {im_nom:7} ({im_nom*100//max(im_tot,1):3}%)",
f" ANÓNIMAS (Google no las nombra) {anon:7} ({anon*100//max(im_tot,1):3}%)"]
if im_tot and anon / im_tot > 0.7:
L.append(" ⚠ la mayor parte del volumen es cola larga sin nombre: en esas")
L.append(" páginas el CTR bajo NO se arregla reescribiendo el titular.")
L.append("\n ── por página ──")
L.append(" imp c/nombre anón pos clics página")
orden = sorted(paginas.items(), key=lambda kv: (-kv[1]["imp"], kv[0]))
for u, v in orden:
if v["imp"] < minimo:
continue
nom = por_pag.get(u, {}).get("imp", 0)
L.append(f" {v['imp']:5} {nom:7} {v['imp']-nom:5} {v['pos']:4.1f} "
f"{v['clics']:5} {u.rstrip('/').rsplit('/', 1)[-1][:44]}")
# Las consultas con nombre, que es lo único sobre lo que se puede actuar.
cuantas = 25 if filtro else 12
todas = sorted(((c_, cl, im, po, pag) for pag, d in por_pag.items()
for c_, cl, im, po in d["q"]), key=lambda x: (-x[2], x[0]))
L.append(f"\n ── consultas con nombre (top {cuantas} de {len(todas)}) ──")
for c_, cl, im, po, pag in todas[:cuantas]:
L.append(f" {im:5} imp {cl:3} clics pos {po:4.1f} {c_[:52]}"
+ ("" if filtro else f"{pag.rstrip('/').rsplit('/', 1)[-1][:26]}"))
return "\n".join(L)
def cmd_consultas(a):
c = bd()
for sitio in (["en", "es"] if a.site == "todos" else [a.site]):
print(consultas_sitio(c, sitio, a.dias, a.pagina, a.min))
print()
return 0
def cmd_informe(a): def cmd_informe(a):
c = bd() c = bd()
partes, hallazgos = [], False partes, hallazgos = [], False
@@ -590,6 +735,14 @@ def main():
p.add_argument("--telegram", action="store_true") p.add_argument("--telegram", action="store_true")
p.set_defaults(func=cmd_informe) p.set_defaults(func=cmd_informe)
p = sub.add_parser("consultas", help="página × consulta: de qué búsquedas sale cada URL")
p.add_argument("--site", choices=("en", "es", "todos"), default="todos")
p.add_argument("--pagina", help="filtra por subcadena del slug/URL")
p.add_argument("--dias", type=int, default=28)
p.add_argument("--min", type=int, default=10,
help="impresiones mínimas para listar una página")
p.set_defaults(func=cmd_consultas)
p = sub.add_parser("antes-despues", help="misma ventana a los dos lados de una fecha") 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("--corte", required=True)
p.add_argument("--dias", type=int, default=28) p.add_argument("--dias", type=int, default=28)