hreflang: convertirlo en regla del auditor y en paso del remate

Hasta ahora el hreflang era un estado —puesto una vez a mano— y no una
regla. Un slug que cambiara habría dejado el enlace apuntando al artículo
viejo sin que nada avisara.

El auditor gana dos reglas: hreflang.missing (pareja declarada sin bloque)
y hreflang.stale (bloque que ya no coincide con la pareja). Viven en
seo_hreflang y no en seo_rules porque necesitan hreflang-parejas.md, que es
una decisión editorial curada; el motor sigue sin saber que hay parejas.

Probadas en los cinco estados, no solo en el feliz: dispara sin bloque,
dispara con el bloque apuntando a un slug viejo, y se calla cuando está
correcto, cuando el post no es de ninguna pareja y cuando aún es borrador.

Y el remate gana el paso 3c: buscar el espejo en el otro idioma y dejarlo
en el informe. Con la advertencia explícita de decidir por TÍTULO y no por
vector, y las dos reglas duras — solo espejos publicados, y uno a uno.
This commit is contained in:
ChemaVX
2026-07-28 07:39:45 +00:00
parent bdcab16ad9
commit cffd174d4a
3 changed files with 68 additions and 1 deletions
+26
View File
@@ -75,6 +75,29 @@ CHECKLIST — en este orden (el SEO se deriva del texto FINAL, nunca antes):
(compruébalo: descarga el origen y busca la frase). Si ningún candidato pasa (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. de 0,55 de similitud, dilo en vez de inventar un enlace forzado.
3c) ESPEJO — ¿existe este mismo caso en el otro idioma?
La casa publica en dos idiomas y dos dominios. Sin `hreflang`, Google no
sabe que las dos piezas del mismo caso son la misma pieza y las trata como
dos sitios sueltos compitiendo. Hasta el 2026-07-28 no había NI UNO.
Busca el espejo en el OTRO sitio (si $site es «es», mira el EN, y al revés).
El criterio que decide es el TÍTULO — año y nombre propio del caso —, NO el
parecido semántico: los dos artículos de Immaculate Constellation puntúan
0,774 entre sí y uno sin ninguna relación puntúa 0,778. Si te fías del
vector, emparejarás mal.
python3 ~/seo-tools/seo_semantic.py afines <slug-tuyo> --site <el-otro>
Reglas duras: solo vale un espejo **publicado** (a uno programado el enlace
sería un 404), y es **uno a uno** — si dos artículos tuyos apuntarían al
mismo, no es espejo de ninguno.
Si lo encuentras, NO lo apliques: añadir el bloque toca los DOS posts, uno
de ellos del otro sitio. Deja en el informe la línea lista para
`hreflang-parejas.md` y el comando. Si no hay espejo, dilo — es lo normal
en artículos que solo existen en un idioma.
4) METAS + ALT — con seo_finish.py: 4) METAS + ALT — con seo_finish.py:
- `python3 ~/seo-tools/seo_finish.py prep <slug> --site $site` descarga la - `python3 ~/seo-tools/seo_finish.py prep <slug> --site $site` descarga la
imagen destacada a ~/.cache/seo-finish/. imagen destacada a ~/.cache/seo-finish/.
@@ -105,4 +128,7 @@ anchas, <3000 caracteres):
verificado en la prosa del origen. Va aparte de los enlaces del paso 3 y no 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 se puede omitir: es lo único del checklist que decide si Google llega a
encontrar el artículo. encontrar el artículo.
- ESPEJO: el slug del artículo equivalente en el otro idioma, o «no existe».
Si existe y está publicado, la línea para `hreflang-parejas.md` y el
recordatorio de rematar con `seo_hreflang.py aplicar`.
- Cierra recordando que sigue en borrador, listo para publicar. - Cierra recordando que sigue en borrador, listo para publicar.
+9 -1
View File
@@ -27,6 +27,7 @@ from collections import Counter
import seo_rules as R import seo_rules as R
import seo_exceptions as X import seo_exceptions as X
import seo_hreflang as HL
def partition(post, violations, site="en"): def partition(post, violations, site="en"):
@@ -102,9 +103,16 @@ def main():
# el ES contaría cero enlaces internos en los 31 posts. # el ES contaría cero enlaces internos en los 31 posts.
R.usar_sitio(args.site) R.usar_sitio(args.site)
# Las parejas espejo son una decisión editorial curada, no una regla del
# motor: el auditor las inyecta y seo_rules sigue sin saber que existen.
try:
parejas = HL.lee_parejas()
except OSError:
parejas = []
results = [] # (post, actionable, accepted, info, score) results = [] # (post, actionable, accepted, info, score)
for p in posts: for p in posts:
v = R.check_post(p) v = R.check_post(p) + HL.violaciones(p, args.site, parejas)
actionable, accepted, info = partition(p, v, args.site) actionable, accepted, info = partition(p, v, args.site)
# score ranks worst-first on ACTIONABLE severity only — accepted waivers # score ranks worst-first on ACTIONABLE severity only — accepted waivers
# and INFO don't push a post up the list. # and INFO don't push a post up the list.
+33
View File
@@ -129,6 +129,39 @@ def cambios():
return plan, avisos, parejas return plan, avisos, parejas
def violaciones(post, site, parejas=None):
"""Regla para el auditor: una pareja declarada que NO lo tiene puesto.
Vive aquí y no en seo_rules porque necesita conocer hreflang-parejas.md, que
es una decisión editorial curada a mano y no una regla del motor. El auditor
la inyecta; el motor sigue sin saber nada de parejas.
No basta con que exista el bloque: se comprueba que las URLs sean las de ESTA
pareja. Si un slug cambia, el hreflang se queda apuntando al viejo y esto es
lo único que lo cazaría.
"""
import seo_rules as R
if parejas is None:
parejas = lee_parejas()
idx = {(a if site == "en" else b): (a, b) for a, b in parejas}
par = idx.get(post.get("slug"))
if not par:
return []
if post.get("status") != "published":
return []
cabeza = post.get("codeinjection_head") or ""
esperado = bloque(*par)
if esperado in cabeza:
return []
falta = INICIO not in cabeza
return [R.Violation(
"hreflang.missing" if falta else "hreflang.stale", R.MED,
("sin hreflang y tiene espejo declarado" if falta else
"el hreflang no coincide con la pareja declarada (¿cambió un slug?)")
+ f" — pareja: {par[1] if site == 'en' else par[0]}",
"python3 ~/seo-tools/seo_hreflang.py aplicar")]
def cmd_plan(a): def cmd_plan(a):
plan, avisos, parejas = cambios() plan, avisos, parejas = cambios()
print(f"{len(parejas)} parejas marcadas SI en {os.path.basename(PAREJAS)}") print(f"{len(parejas)} parejas marcadas SI en {os.path.basename(PAREJAS)}")