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