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:
ChemaVX
2026-07-28 06:55:24 +00:00
parent 4609a21f21
commit f7be9ccb3e
3 changed files with 261 additions and 1 deletions
+24
View File
@@ -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
View File
@@ -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
View File
@@ -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 », 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: