From 66540c12e6ab688167a051625a76b9e62522fc86 Mon Sep 17 00:00:00 2001 From: ChemaVX Date: Wed, 29 Jul 2026 16:09:25 +0000 Subject: [PATCH] =?UTF-8?q?gsc:=20p=C3=A1gina=20=C3=97=20consulta,=20y=20l?= =?UTF-8?q?o=20que=20ense=C3=B1a=20es=20que=20el=20CTR=20no=20era=20el=20p?= =?UTF-8?q?roblema?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `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 --- seo_gsc.py | 153 +++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 153 insertions(+) diff --git a/seo_gsc.py b/seo_gsc.py index 0f59e0d..9ac6558 100644 --- a/seo_gsc.py +++ b/seo_gsc.py @@ -29,6 +29,17 @@ Subcomandos: 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 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] El marcador. Primero COBERTURA (impresiones, páginas que reciben 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 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. + * ⚠️ 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 `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, PRIMARY KEY (sitio, dim, fecha, clave)); 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 ( sitio TEXT, dim TEXT, hasta TEXT, cuando TEXT, filas INTEGER); 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 +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 ───────────────────────────────── def descarga(tok, propiedad, inicio, fin, dims): @@ -243,6 +275,40 @@ def cmd_pull(a): len(filas))) c.commit() 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 @@ -381,6 +447,85 @@ def informe_sitio(c, sitio, dias): 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): c = bd() partes, hallazgos = [], False @@ -590,6 +735,14 @@ def main(): p.add_argument("--telegram", action="store_true") 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.add_argument("--corte", required=True) p.add_argument("--dias", type=int, default=28)