seo: quién enlaza HACIA un post, que es lo que decide si Google lo encuentra
Toda la caja miraba los enlaces en un solo sentido — a quién enlazo yo — y
esa es la pregunta del lector, no la de Google. Dos posts de EN publicados
el 21-jul seguían siendo «URL is unknown to Google» una semana después:
tenían enlaces entrantes, pero solo desde artículos con 8 y 12 impresiones
de por vida, páginas que Google casi no visita.
seo_semantic gana dos subcomandos: `padrinos <slug>`, que ordena candidatos
por tráfico REAL del origen (Search Console) en vez de por similitud, y
`descubrimiento`, que lista los publicados a los que no llega ni un enlace
desde el tercio fuerte del sitio. Salen 21 de 32 en EN y 21 de 31 en ES.
Ambos descartan los posts con canonical_url propio: Ghost los excluye del
sitemap por consolidados, así que apadrinar desde ahí no sirve de nada — el
de Grusch de mayo tiene 1.728 impresiones y habría encabezado la lista.
seo_gsc gana `inspecciona`, que pregunta el veredicto a Google en vez de
deducirlo. Es lo que separa «posiciona mal» de «no lo ha visto nunca», que
son problemas opuestos y se arreglan al revés.
Y el checklist del remate incorpora el paso 3b: el informe tiene que traer
el comando de enlace entrante listo para copiar. No lo aplica el agente
porque eso es editar otro post publicado, y eso sigue prohibido.
⚠️ Anotado en el docstring: contar enlaces internos desde el HTML público da
«cero huérfanos», porque el tema mete navegación y feed de relacionados que
parecen enlaces del cuerpo. La verdad está en el html del CLI de Ghost.
This commit is contained in:
@@ -55,6 +55,26 @@ CHECKLIST — en este orden (el SEO se deriva del texto FINAL, nunca antes):
|
||||
Primero SIN --apply para revisar las inserciones. Elige anchors que ya
|
||||
existan en la prosa.
|
||||
|
||||
3b) PADRINO — quién enlaza HACIA este post (obligatorio en el informe)
|
||||
|
||||
Los enlaces del paso 3 salen de aquí hacia fuera y sirven al lector. Este
|
||||
paso es el contrario y sirve a Google: si nadie enlaza al post nuevo desde
|
||||
una página que Google ya visita, tarda semanas en descubrirlo. Medido el
|
||||
2026-07-28: dos posts de EN publicados el 21-jul seguían siendo «URL
|
||||
desconocida para Google» una semana después. Tenían enlaces entrantes, pero
|
||||
solo desde artículos con 8 y 12 impresiones de por vida.
|
||||
|
||||
python3 ~/seo-tools/seo_semantic.py padrinos $slug --site $site -n 3
|
||||
|
||||
Ordena por tráfico REAL del origen (Search Console), no por parecido, y
|
||||
descarta los que ya enlazan y los consolidados con canonical_url.
|
||||
|
||||
NO lo apliques: escribir ese enlace significa editar OTRO post publicado, y
|
||||
eso te está prohibido. Lo que tienes que hacer es dejar en el informe el
|
||||
comando exacto, con un anchor que exista de verdad en la prosa del origen
|
||||
(compruébalo: descarga el origen y busca la frase). Si ningún candidato pasa
|
||||
de 0,55 de similitud, dilo en vez de inventar un enlace forzado.
|
||||
|
||||
4) METAS + ALT — con seo_finish.py:
|
||||
- `python3 ~/seo-tools/seo_finish.py prep <slug> --site $site` descarga la
|
||||
imagen destacada a ~/.cache/seo-finish/.
|
||||
@@ -81,4 +101,8 @@ anchas, <3000 caracteres):
|
||||
- Hallazgos que requieren decisión humana (lo del paso 1 que no tocaste).
|
||||
- Slug final, metas con sus longitudes, nº de enlaces internos añadidos y el
|
||||
veredicto literal del validador.
|
||||
- PADRINO: el comando `seo_link.py` listo para copiar, con el anchor ya
|
||||
verificado en la prosa del origen. Va aparte de los enlaces del paso 3 y no
|
||||
se puede omitir: es lo único del checklist que decide si Google llega a
|
||||
encontrar el artículo.
|
||||
- Cierra recordando que sigue en borrador, listo para publicar.
|
||||
|
||||
+46
@@ -41,6 +41,11 @@ Subcomandos:
|
||||
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 inspecciona <slug…> --site en|es
|
||||
El veredicto de Google sobre una URL: indexada, rastreada cuándo, y a
|
||||
qué canoniza. Distingue «posiciona mal» de «no lo ha visto nunca», que
|
||||
son problemas opuestos. Sale con código 1 si alguna no está indexada.
|
||||
|
||||
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.
|
||||
@@ -449,6 +454,42 @@ def cmd_antes_despues(a):
|
||||
|
||||
# ──────────────────────────────── utilidades ────────────────────────────────
|
||||
|
||||
def cmd_inspecciona(a):
|
||||
"""¿Conoce Google esta URL? Veredicto suyo, no deducción nuestra.
|
||||
|
||||
Es la única forma de distinguir «posiciona mal» de «no lo ha visto». El
|
||||
2026-07-28 dos posts de EN publicados una semana antes salieron con «URL is
|
||||
unknown to Google»: nunca rastreados. Sin esto se habría confundido con un
|
||||
problema de contenido y se habrían reescrito titulares para nada.
|
||||
"""
|
||||
import requests
|
||||
tok = token(carga_credencial())
|
||||
prop = SITIOS[a.site]["propiedad"]
|
||||
base = ("https://www.theexclusionzone.com/" if a.site == "en"
|
||||
else "https://zonadeexclusion.com/")
|
||||
malas = 0
|
||||
for slug in a.slugs:
|
||||
url = slug if slug.startswith("http") else f"{base}{slug.strip('/')}/"
|
||||
r = requests.post(
|
||||
"https://searchconsole.googleapis.com/v1/urlInspection/index:inspect",
|
||||
headers={"Authorization": f"Bearer {tok}"}, timeout=60,
|
||||
json={"inspectionUrl": url, "siteUrl": prop})
|
||||
if r.status_code != 200:
|
||||
print(f" ✗ {r.status_code} {url}\n {r.text[:200]}")
|
||||
malas += 1
|
||||
continue
|
||||
i = r.json()["inspectionResult"]["indexStatusResult"]
|
||||
ok = i.get("verdict") == "PASS"
|
||||
malas += 0 if ok else 1
|
||||
print(f" {'✓' if ok else '⚠'} {i.get('coverageState', '?')}")
|
||||
print(f" rastreo {i.get('lastCrawlTime', '—')[:10]} "
|
||||
f"robots {i.get('robotsTxtState', '?')}")
|
||||
if i.get("googleCanonical") and i["googleCanonical"] != url:
|
||||
print(f" ⚠ Google canoniza a: {i['googleCanonical']}")
|
||||
print(f" {url}")
|
||||
return 1 if malas else 0
|
||||
|
||||
|
||||
def cmd_sitios(a):
|
||||
sa = carga_credencial()
|
||||
print(f"cuenta de servicio: {sa['client_email']}")
|
||||
@@ -555,6 +596,11 @@ def main():
|
||||
p.add_argument("--site", choices=("en", "es", "todos"), default="todos")
|
||||
p.set_defaults(func=cmd_antes_despues)
|
||||
|
||||
p = sub.add_parser("inspecciona", help="¿conoce Google estas URLs?")
|
||||
p.add_argument("slugs", nargs="+", help="slugs o URLs completas")
|
||||
p.add_argument("--site", choices=("en", "es"), required=True)
|
||||
p.set_defaults(func=cmd_inspecciona)
|
||||
|
||||
p = sub.add_parser("comprueba", help="canario del camino real")
|
||||
p.set_defaults(func=cmd_comprueba)
|
||||
|
||||
|
||||
+191
-1
@@ -8,9 +8,20 @@ vectores contesta dos preguntas:
|
||||
· afines <slug> → los N artículos PUBLICADOS más parecidos, para sugerir
|
||||
enlaces internos al rematar un borrador. Es la palanca
|
||||
de indexación que queda después de IndexNow.
|
||||
· padrinos <slug> → lo INVERSO: qué páginas fuertes deberían enlazar aquí.
|
||||
`afines` sirve al lector, `padrinos` sirve a Google, y
|
||||
casi nunca dan la misma respuesta.
|
||||
· descubrimiento → publicados a los que solo se llega desde páginas que
|
||||
Google apenas visita.
|
||||
· canibalizacion → pares demasiado parecidos DENTRO de un sitio: compiten
|
||||
por la misma búsqueda y se hacen sombra.
|
||||
|
||||
⚠️ Para contar enlaces internos, la verdad está en el `html` que devuelve el
|
||||
CLI de Ghost, NO en la página renderizada: el tema mete navegación anterior /
|
||||
siguiente y un feed de relacionados que parecen enlaces del cuerpo y no lo son.
|
||||
Contarlos desde el HTML público dice «cero huérfanos» cuando la realidad son 21
|
||||
de 32 (comprobado el 2026-07-28, y la primera medición salió mal por esto).
|
||||
|
||||
NUNCA escribe en el blog. Solo imprime. Quien escribe el enlace es seo_link.py,
|
||||
y publicar es decisión de Jose.
|
||||
|
||||
@@ -25,6 +36,8 @@ MJ-12, ver seo_link.py).
|
||||
|
||||
Uso:
|
||||
python3 seo_semantic.py afines <slug> --site es [-n 4] [--json]
|
||||
python3 seo_semantic.py padrinos <slug> --site en [-n 5] [--json]
|
||||
python3 seo_semantic.py descubrimiento --site en [--todos] [--json]
|
||||
python3 seo_semantic.py canibalizacion --site es [--umbral 0.86] [--json]
|
||||
python3 seo_semantic.py estado
|
||||
python3 seo_semantic.py distribucion --site es # para calibrar el umbral
|
||||
@@ -156,6 +169,11 @@ def carga(sitio, refrescar=False, verboso=True):
|
||||
"url": f"{cfg['base']}/{slug}/",
|
||||
"hash": hh,
|
||||
"chars": len(txt),
|
||||
# Ghost excluye del sitemap los posts con canonical_url propio: son
|
||||
# versiones consolidadas en otra URL. Siguen publicados y pueden
|
||||
# arrastrar tráfico histórico, pero NO sirven de padrino — el enlace
|
||||
# saldría de una página que el propio sitio declara secundaria.
|
||||
"canonico": p.get("canonical_url"),
|
||||
# se recalcula en cada pasada (es gratis) para que no envejezca
|
||||
"enlaza_a": enlaces_de(p, cfg["base"]),
|
||||
}
|
||||
@@ -180,6 +198,54 @@ def carga(sitio, refrescar=False, verboso=True):
|
||||
return vivos
|
||||
|
||||
|
||||
# ---------- tráfico real (Search Console) ----------
|
||||
|
||||
# Sin esto, "enlázalo desde un artículo afín" es un consejo a ciegas: da igual
|
||||
# que el origen sea el artículo con más tráfico del sitio o uno que no ha
|
||||
# recibido una impresión en su vida. Medido el 2026-07-28, esa diferencia es la
|
||||
# que explica que dos posts de EN publicados el 21-jul siguieran siendo «URL
|
||||
# desconocida para Google» una semana después: estaban enlazados, sí, pero solo
|
||||
# desde páginas con 8 y 12 impresiones DE POR VIDA, que Google casi no visita.
|
||||
BD_GSC = os.path.expanduser("~/.local/state/seo-gsc/gsc.db")
|
||||
|
||||
|
||||
def impresiones(sitio):
|
||||
"""{slug: impresiones acumuladas}. Vacío si aún no hay marcador."""
|
||||
if not os.path.exists(BD_GSC):
|
||||
return {}
|
||||
import sqlite3
|
||||
with sqlite3.connect(f"file:{BD_GSC}?mode=ro", uri=True) as c:
|
||||
return {k.rstrip("/").rsplit("/", 1)[-1]: v for k, v in c.execute(
|
||||
"SELECT clave, SUM(impresiones) FROM filas "
|
||||
"WHERE sitio=? AND dim='page' GROUP BY clave", (sitio,))}
|
||||
|
||||
|
||||
def tercio_fuerte(regs, impr):
|
||||
"""Los slugs publicados del tercio superior por impresiones.
|
||||
|
||||
Es el listón de «página fuerte»: relativo al sitio y no un número absoluto,
|
||||
porque 200 impresiones son mucho en ES y poco en EN.
|
||||
"""
|
||||
pub = [s for s, r in regs.items()
|
||||
if r.get("estado") == "published" and not r.get("canonico")]
|
||||
if not pub:
|
||||
return set()
|
||||
orden = sorted(pub, key=lambda s: -impr.get(s, 0))
|
||||
return set(orden[:max(1, len(orden) // 3)])
|
||||
|
||||
|
||||
def entrantes(regs):
|
||||
"""Invierte enlaza_a: {slug: [slugs publicados que lo enlazan]}."""
|
||||
dentro = {s: [] for s in regs}
|
||||
for origen, r in regs.items():
|
||||
if r.get("estado") != "published":
|
||||
continue
|
||||
for destino in r.get("enlaza_a", []):
|
||||
if destino in dentro and destino != origen:
|
||||
dentro[destino].append(origen)
|
||||
return dentro
|
||||
|
||||
|
||||
# ---------- similitud ----------
|
||||
|
||||
def matriz(regs):
|
||||
@@ -245,6 +311,81 @@ def afines(sitio, slug, n, refrescar=False):
|
||||
for v, s in fila[:n]]
|
||||
|
||||
|
||||
SIM_MIN_PADRINO = 0.55 # mismo listón que usa el remate para no forzar enlaces
|
||||
|
||||
|
||||
def padrinos(sitio, slug, n, refrescar=False):
|
||||
"""Lo inverso de `afines`: qué páginas FUERTES deberían enlazar a este post.
|
||||
|
||||
`afines` contesta «a quién enlazo yo», que es lo que necesita un borrador
|
||||
para dar contexto al lector. Esto contesta «quién me enlaza a mí», que es lo
|
||||
que necesita Google para llegar hasta aquí. No es la misma pregunta y la
|
||||
respuesta rara vez coincide: el artículo más parecido suele ser otro post
|
||||
pequeño del mismo rincón temático, y ése no arrastra a nadie.
|
||||
|
||||
Se descartan los que YA enlazan al destino, y se ordena por tráfico del
|
||||
origen — no por similitud — porque entre dos candidatos razonables el que
|
||||
sirve es el que Google visita.
|
||||
"""
|
||||
regs = carga(sitio, refrescar)
|
||||
if slug not in regs:
|
||||
p = post_suelto(sitio, slug)
|
||||
if p is None:
|
||||
sys.exit(f"✗ '{slug}' no existe en {sitio.upper()}. ¿Sitio equivocado?")
|
||||
regs = dict(regs)
|
||||
regs[slug] = {"slug": slug, "titulo": p.get("title"), "estado": p.get("status"),
|
||||
"url": f"{SITIOS[sitio]['base']}/{slug}/",
|
||||
"enlaza_a": enlaces_de(p, SITIOS[sitio]["base"]),
|
||||
"vec": embed([texto_de(p)], endpoint())[0]}
|
||||
impr = impresiones(sitio)
|
||||
fuertes = tercio_fuerte(regs, impr)
|
||||
slugs, S = matriz(regs)
|
||||
i = slugs.index(slug)
|
||||
out = []
|
||||
for j, otro in enumerate(slugs):
|
||||
if j == i or regs[otro].get("estado") != "published":
|
||||
continue
|
||||
if regs[otro].get("canonico"):
|
||||
continue # consolidado en otra URL: no está ni en el sitemap
|
||||
if slug in regs[otro].get("enlaza_a", []):
|
||||
continue # ya nos enlaza: no hay nada que pedirle
|
||||
sim = float(S[i][j])
|
||||
if sim < SIM_MIN_PADRINO:
|
||||
continue
|
||||
out.append({"slug": otro, "titulo": regs[otro]["titulo"],
|
||||
"url": regs[otro]["url"], "similitud": round(sim, 4),
|
||||
"impresiones": impr.get(otro, 0),
|
||||
"fuerte": otro in fuertes})
|
||||
out.sort(key=lambda r: (-r["impresiones"], -r["similitud"], r["slug"]))
|
||||
return out[:n]
|
||||
|
||||
|
||||
def descubrimiento(sitio, refrescar=False):
|
||||
"""Posts publicados a los que solo llega enlace desde páginas flojas.
|
||||
|
||||
Un post puede no ser huérfano y aun así ser invisible: si los únicos caminos
|
||||
que llevan hasta él pasan por artículos que Google apenas rastrea, tarda
|
||||
semanas en descubrirlo — o no lo descubre.
|
||||
"""
|
||||
regs = carga(sitio, refrescar)
|
||||
impr = impresiones(sitio)
|
||||
fuertes = tercio_fuerte(regs, impr)
|
||||
dentro = entrantes(regs)
|
||||
out = []
|
||||
for s, r in regs.items():
|
||||
if r.get("estado") != "published" or r.get("canonico"):
|
||||
continue
|
||||
orig = sorted(dentro[s], key=lambda o: -impr.get(o, 0))
|
||||
out.append({"slug": s, "titulo": r["titulo"],
|
||||
"impresiones": impr.get(s, 0),
|
||||
"entrantes": len(orig),
|
||||
"apadrinado": any(o in fuertes for o in orig),
|
||||
"mejor_origen": orig[0] if orig else None,
|
||||
"mejor_origen_impr": impr.get(orig[0], 0) if orig else 0})
|
||||
out.sort(key=lambda r: (r["apadrinado"], r["mejor_origen_impr"], r["slug"]))
|
||||
return out
|
||||
|
||||
|
||||
def canibalizacion(sitio, umbral, refrescar=False):
|
||||
regs = carga(sitio, refrescar)
|
||||
slugs, S = matriz(regs)
|
||||
@@ -283,6 +424,17 @@ def main():
|
||||
a.add_argument("--site", required=True, choices=SITIOS)
|
||||
a.add_argument("-n", type=int, default=4)
|
||||
|
||||
pa = sub.add_parser("padrinos", help="qué páginas fuertes deberían enlazar a este post")
|
||||
pa.add_argument("slug")
|
||||
pa.add_argument("--site", required=True, choices=SITIOS)
|
||||
pa.add_argument("-n", type=int, default=5)
|
||||
|
||||
de = sub.add_parser("descubrimiento",
|
||||
help="posts a los que solo llega enlace desde páginas flojas")
|
||||
de.add_argument("--site", required=True, choices=SITIOS)
|
||||
de.add_argument("--todos", action="store_true",
|
||||
help="listar también los que sí están apadrinados")
|
||||
|
||||
c = sub.add_parser("canibalizacion", help="pares que compiten por la misma búsqueda")
|
||||
c.add_argument("--site", required=True, choices=SITIOS)
|
||||
# 0.78 calibrado el 2026-07-25 contra el corpus real, no a ojo. El reparto
|
||||
@@ -299,7 +451,7 @@ def main():
|
||||
|
||||
sub.add_parser("estado", help="qué hay en el caché")
|
||||
|
||||
for p in (a, c, d):
|
||||
for p in (a, c, d, pa, de):
|
||||
p.add_argument("--json", action="store_true")
|
||||
p.add_argument("--refrescar", action="store_true",
|
||||
help="re-embeber todo, ignorando el caché")
|
||||
@@ -330,6 +482,44 @@ def main():
|
||||
print(f" {r['url']}")
|
||||
return
|
||||
|
||||
if ar.cmd == "padrinos":
|
||||
res = padrinos(ar.site, ar.slug, ar.n, ar.refrescar)
|
||||
if ar.json:
|
||||
print(json.dumps(res, ensure_ascii=False, indent=2))
|
||||
elif not res:
|
||||
print(f"Nadie afín (≥{SIM_MIN_PADRINO}) que no lo enlace ya. "
|
||||
f"O está bien apadrinado, o el corpus no da para un enlace honesto.")
|
||||
else:
|
||||
print(f"\nQuién debería enlazar a «{ar.slug}» ({ar.site.upper()}), "
|
||||
f"por tráfico del origen:\n")
|
||||
for r in res:
|
||||
print(f" {r['impresiones']:6} impr sim {r['similitud']:.3f}"
|
||||
f"{' ← tercio fuerte' if r['fuerte'] else ''}")
|
||||
print(f" {r['titulo']}")
|
||||
print(f" {r['slug']}")
|
||||
print("\n El enlace hay que escribirlo en el post ORIGEN:")
|
||||
print(f" python3 ~/seo-tools/seo_link.py {res[0]['slug']} "
|
||||
f"--site {ar.site} --link \"<anchor>={ar.slug}\"")
|
||||
return
|
||||
|
||||
if ar.cmd == "descubrimiento":
|
||||
res = descubrimiento(ar.site, ar.refrescar)
|
||||
if ar.json:
|
||||
print(json.dumps(res, ensure_ascii=False, indent=2))
|
||||
return
|
||||
malos = [r for r in res if not r["apadrinado"]]
|
||||
print(f"\n{ar.site.upper()}: {len(res)} publicados, "
|
||||
f"{len(malos)} sin un solo enlace desde el tercio fuerte\n")
|
||||
for r in (res if ar.todos else malos):
|
||||
marca = "✓" if r["apadrinado"] else "⚠"
|
||||
origen = (f"mejor origen: {r['mejor_origen'][:40]} "
|
||||
f"({r['mejor_origen_impr']} impr)" if r["mejor_origen"]
|
||||
else "HUÉRFANO: nadie lo enlaza")
|
||||
print(f" {marca} {r['impresiones']:6} impr propias "
|
||||
f"{r['entrantes']} entrantes {r['slug'][:44]}")
|
||||
print(f" {origen}")
|
||||
return
|
||||
|
||||
if ar.cmd == "canibalizacion":
|
||||
res = canibalizacion(ar.site, ar.umbral, ar.refrescar)
|
||||
if ar.json:
|
||||
|
||||
Reference in New Issue
Block a user