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:
+153
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user