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
+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 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: