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