hreflang: declarar las 16 parejas espejo ES ↔ EN
Hasta hoy no había NI UN hreflang en ninguno de los dos blogs. Google no tenía forma de saber que los artículos del mismo caso en los dos idiomas son la misma pieza, así que servía el que le parecía y las señales de cada uno se quedaban sueltas en su lado. Las parejas NO se infieren, se curan a mano en hreflang-parejas.md. Los embeddings no valen a esta granularidad y conviene que quede escrito: los DOS artículos de Immaculate Constellation puntúan 0,774 entre sí y uno sin relación puntúa 0,778; y el espejo real del artículo ES de bases nucleares puntúa 0,719, por debajo de tres PURSUE que no lo son. La firma del título (año + nombre propio) decide; el vector solo desempata. Dos descartes con motivo: el forense del vídeo de Roswell no es el espejo de la pieza ES de tres hilos (comparten un hecho, no un artículo), y ninguno de los tres PURSUE es la traducción del artículo ES de bases nucleares — ese espejo se llama «Nuclear Bases and UAP» y está PROGRAMADO, así que entra en agosto. Escribe entre marcas en codeinjection_head, así que es reescribible y no pisa lo que hubiera. Verificado contra las 32 páginas PÚBLICAS comparando las URLs exactas, no solo la presencia de las etiquetas: un par cruzado declararía las dos versiones y habría pasado una comprobación laxa.
This commit is contained in:
+6
-4
@@ -127,16 +127,18 @@ sim 0.749 roswell
|
|||||||
Roswell's Viral "Crater" Video: A Frame-by-Frame Forensic Analysis
|
Roswell's Viral "Crater" Video: A Frame-by-Frame Forensic Analysis
|
||||||
ES roswell-1947-preguntas-sin-respuesta-78-anos
|
ES roswell-1947-preguntas-sin-respuesta-78-anos
|
||||||
Un vídeo, un diario y una confesión: lo que Roswell sigue sin responder
|
Un vídeo, un diario y una confesión: lo que Roswell sigue sin responder
|
||||||
NOTA: DUDA TUYA. El título ES empieza por «Un vídeo…», que encaja con el análisis forense del EN, pero el resto («un diario y una confesión») suena a pieza más amplia. Tú los escribiste: ¿es el espejo o no?
|
RESUELTO 2026-07-28 leyendo los dos: NO son el mismo artículo. El EN es el forense de la cinta 341.ROSWELL.41; el ES es una pieza de tres hilos (vídeo, diario de Marcel, confesión de Haut) que además enlaza al Roswell principal como relato base. Comparten un hecho, no un artículo.
|
||||||
decision: ?
|
NOTA: el título ES empieza por «Un vídeo…», que encaja con el análisis forense del EN, pero el resto («un diario y una confesión») suena a pieza más amplia. Tú los escribiste: ¿es el espejo o no?
|
||||||
|
decision: NO
|
||||||
|
|
||||||
sim 0.726 pursue
|
sim 0.726 pursue
|
||||||
EN pursue-release-3-cia-confession-apollo-uap-files-2026
|
EN pursue-release-3-cia-confession-apollo-uap-files-2026
|
||||||
PURSUE Release 3: The CIA Confession, Apollo Lunar Anomalies, and 294 Files That Raise
|
PURSUE Release 3: The CIA Confession, Apollo Lunar Anomalies, and 294 Files That Raise
|
||||||
ES archivos-pursue-uap-bases-nucleares-desclasificado
|
ES archivos-pursue-uap-bases-nucleares-desclasificado
|
||||||
Los Archivos PURSUE y las Bases Nucleares: Lo que el Gobierno Acaba de Confirmar
|
Los Archivos PURSUE y las Bases Nucleares: Lo que el Gobierno Acaba de Confirmar
|
||||||
NOTA: DUDA TUYA. Tres artículos EN de PURSUE apuntan al MISMO ES, y hreflang es 1 a 1: solo uno puede declararlo. Por vector gana éste (0,726); por tema encaja mejor el Release 4, que va de Pantex y Los Álamos, o sea bases nucleares. Elige uno y deja los otros dos en NO.
|
RESUELTO 2026-07-28: el espejo real del artículo ES es «Nuclear Bases and UAP: Six Decades of Documented Incursions», que está PROGRAMADO. Ninguno de los tres PURSUE es su traducción; el par entra cuando se publique.
|
||||||
decision: ?
|
NOTA: Tres artículos EN de PURSUE apuntan al MISMO ES, y hreflang es 1 a 1: solo uno puede declararlo. Por vector gana éste (0,726); por tema encaja mejor el Release 4, que va de Pantex y Los Álamos, o sea bases nucleares. Elige uno y deja los otros dos en NO.
|
||||||
|
decision: NO
|
||||||
|
|
||||||
sim 0.722 pursue
|
sim 0.722 pursue
|
||||||
EN pursue-release-4-pantex-los-alamos-apollo-17-uap-files
|
EN pursue-release-4-pantex-los-alamos-apollo-17-uap-files
|
||||||
|
|||||||
+222
@@ -0,0 +1,222 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Tool I — hreflang recíproco entre los artículos espejo ES ↔ EN.
|
||||||
|
|
||||||
|
Qué problema resuelve. La casa publica el mismo caso en dos idiomas y en dos
|
||||||
|
dominios, y hasta el 2026-07-28 no había NI UN hreflang en ninguno de los dos.
|
||||||
|
Google no tenía forma de saber que «Varginha 1996: El Caso OVNI Más Intrigante
|
||||||
|
de Brasil» y «Brazil's 1996 Varginha Incident» son el mismo artículo en otro
|
||||||
|
idioma, así que servía el que le parecía y las señales de cada uno se quedaban
|
||||||
|
sueltas en su lado.
|
||||||
|
|
||||||
|
⚠️ POR QUÉ LAS PAREJAS SE CURAN A MANO Y NO SE INFIEREN. Se intentó con bge-m3
|
||||||
|
y no vale: los DOS artículos de Immaculate Constellation puntúan 0,774 entre sí,
|
||||||
|
y uno que no tiene nada que ver puntúa 0,778. Y el espejo real del artículo ES
|
||||||
|
de bases nucleares puntúa 0,719, por debajo de tres artículos de PURSUE que no
|
||||||
|
lo son. Un hreflang equivocado le dice a Google que dos artículos DISTINTOS son
|
||||||
|
el mismo, que es peor que no ponerlo. Por eso la fuente de verdad es
|
||||||
|
hreflang-parejas.md, revisado por una persona, y esta herramienta solo lo
|
||||||
|
ejecuta.
|
||||||
|
|
||||||
|
Cómo escribe. En `codeinjection_head` de cada post, entre marcas, para poder
|
||||||
|
reescribirlo o retirarlo sin tocar lo que hubiera ya:
|
||||||
|
|
||||||
|
<!-- hreflang:inicio -->…<!-- hreflang:fin -->
|
||||||
|
|
||||||
|
Cada página declara las DOS versiones, incluida ella misma: el autorreferente no
|
||||||
|
es opcional, Google descarta el grupo si falta. x-default apunta al EN por ser
|
||||||
|
la audiencia más amplia.
|
||||||
|
|
||||||
|
⚠️ Solo se emparejan PUBLICADOS. Varios espejos existen pero están PROGRAMADOS
|
||||||
|
para agosto: un hreflang a una URL que aún no existe apunta a un 404.
|
||||||
|
|
||||||
|
Uso:
|
||||||
|
python3 seo_hreflang.py plan # qué haría (no escribe)
|
||||||
|
python3 seo_hreflang.py aplicar # escribe, con copia previa
|
||||||
|
python3 seo_hreflang.py comprueba # lo verifica en la web pública
|
||||||
|
"""
|
||||||
|
import argparse
|
||||||
|
import datetime
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
|
||||||
|
PAREJAS = os.path.join(os.path.dirname(os.path.abspath(__file__)), "hreflang-parejas.md")
|
||||||
|
BDIR = os.path.expanduser("~/link-batch-backup")
|
||||||
|
SITIOS = {
|
||||||
|
"en": {"cli": "ghst-en", "base": "https://www.theexclusionzone.com"},
|
||||||
|
"es": {"cli": "ghst-es", "base": "https://zonadeexclusion.com"},
|
||||||
|
}
|
||||||
|
INICIO, FIN = "<!-- hreflang:inicio -->", "<!-- hreflang:fin -->"
|
||||||
|
|
||||||
|
|
||||||
|
def sh(cmd, timeout=300):
|
||||||
|
return subprocess.run(cmd, shell=True, capture_output=True, text=True, timeout=timeout)
|
||||||
|
|
||||||
|
|
||||||
|
def lee_parejas():
|
||||||
|
"""Bloques con `decision: SI` del fichero revisado a mano."""
|
||||||
|
texto = open(PAREJAS).read()
|
||||||
|
out = []
|
||||||
|
for bloque in re.split(r"\nsim ", texto)[1:]:
|
||||||
|
m_en = re.search(r"^ EN (\S+)", bloque, re.M)
|
||||||
|
m_es = re.search(r"^ ES (\S+)", bloque, re.M)
|
||||||
|
m_d = re.search(r"^ decision:\s*(\S+)", bloque, re.M)
|
||||||
|
if not (m_en and m_es and m_d):
|
||||||
|
continue
|
||||||
|
if m_d.group(1).upper() != "SI":
|
||||||
|
continue
|
||||||
|
out.append((m_en.group(1), m_es.group(1)))
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def bloque(slug_en, slug_es):
|
||||||
|
en = f"{SITIOS['en']['base']}/{slug_en}/"
|
||||||
|
es = f"{SITIOS['es']['base']}/{slug_es}/"
|
||||||
|
return "\n".join([
|
||||||
|
INICIO,
|
||||||
|
f'<link rel="alternate" hreflang="en" href="{en}">',
|
||||||
|
f'<link rel="alternate" hreflang="es" href="{es}">',
|
||||||
|
f'<link rel="alternate" hreflang="x-default" href="{en}">',
|
||||||
|
FIN,
|
||||||
|
])
|
||||||
|
|
||||||
|
|
||||||
|
def fusiona(actual, nuevo):
|
||||||
|
"""Sustituye SOLO nuestro bloque; lo que hubiera fuera se queda tal cual."""
|
||||||
|
actual = actual or ""
|
||||||
|
if INICIO in actual and FIN in actual:
|
||||||
|
return re.sub(re.escape(INICIO) + r".*?" + re.escape(FIN), nuevo, actual, flags=re.S)
|
||||||
|
return (actual.rstrip() + "\n" + nuevo).strip() if actual.strip() else nuevo
|
||||||
|
|
||||||
|
|
||||||
|
def trae(site, slugs):
|
||||||
|
"""{slug: post} con id, estado y codeinjection_head, por fichero (el pipe
|
||||||
|
trunca a 64 KB, cicatriz compartida con seo_audit)."""
|
||||||
|
fd, path = tempfile.mkstemp(suffix=".json")
|
||||||
|
os.close(fd)
|
||||||
|
try:
|
||||||
|
sh(f'{SITIOS[site]["cli"]} post list --limit all '
|
||||||
|
f'--fields id,slug,status,codeinjection_head --json > {path}')
|
||||||
|
posts = json.load(open(path))["posts"]
|
||||||
|
finally:
|
||||||
|
os.unlink(path)
|
||||||
|
return {p["slug"]: p for p in posts if p["slug"] in slugs}
|
||||||
|
|
||||||
|
|
||||||
|
def cambios():
|
||||||
|
parejas = lee_parejas()
|
||||||
|
en = trae("en", {a for a, _ in parejas})
|
||||||
|
es = trae("es", {b for _, b in parejas})
|
||||||
|
plan, avisos = [], []
|
||||||
|
for a, b in parejas:
|
||||||
|
pa, pb = en.get(a), es.get(b)
|
||||||
|
if not pa or not pb:
|
||||||
|
avisos.append(f"no encontrado: {a if not pa else b}")
|
||||||
|
continue
|
||||||
|
if pa["status"] != "published" or pb["status"] != "published":
|
||||||
|
avisos.append(f"{a} / {b}: alguno no está publicado ({pa['status']}/{pb['status']}) "
|
||||||
|
"— un hreflang a una URL futura es un 404")
|
||||||
|
continue
|
||||||
|
blq = bloque(a, b)
|
||||||
|
for site, p in (("en", pa), ("es", pb)):
|
||||||
|
nuevo = fusiona(p.get("codeinjection_head"), blq)
|
||||||
|
if nuevo != (p.get("codeinjection_head") or ""):
|
||||||
|
plan.append((site, p, nuevo))
|
||||||
|
return plan, avisos, parejas
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_plan(a):
|
||||||
|
plan, avisos, parejas = cambios()
|
||||||
|
print(f"{len(parejas)} parejas marcadas SI en {os.path.basename(PAREJAS)}")
|
||||||
|
for w in avisos:
|
||||||
|
print(f" ⚠ {w}")
|
||||||
|
print(f"\n{len(plan)} posts a modificar:")
|
||||||
|
for site, p, _ in plan:
|
||||||
|
ya = "reescribe" if INICIO in (p.get("codeinjection_head") or "") else "añade"
|
||||||
|
otro = "con contenido previo" if (p.get("codeinjection_head") or "").strip() else "vacío"
|
||||||
|
print(f" [{site}] {ya:9} ({otro:19}) {p['slug'][:52]}")
|
||||||
|
if plan:
|
||||||
|
print("\n(plan: no se ha escrito nada — repite con `aplicar`)")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_aplicar(a):
|
||||||
|
plan, avisos, _ = cambios()
|
||||||
|
for w in avisos:
|
||||||
|
print(f" ⚠ {w}")
|
||||||
|
if not plan:
|
||||||
|
print("nada que hacer: ya está todo puesto")
|
||||||
|
return 0
|
||||||
|
os.makedirs(BDIR, exist_ok=True)
|
||||||
|
ts = datetime.datetime.now().strftime("%Y%m%d-%H%M%S")
|
||||||
|
bpath = os.path.join(BDIR, f"hreflang-pristine-{ts}.json")
|
||||||
|
with open(bpath, "w") as f:
|
||||||
|
json.dump([{"site": s, "id": p["id"], "slug": p["slug"],
|
||||||
|
"codeinjection_head": p.get("codeinjection_head")}
|
||||||
|
for s, p, _ in plan], f, ensure_ascii=False, indent=1)
|
||||||
|
print(f"copia previa: {bpath}\n")
|
||||||
|
fallos = 0
|
||||||
|
for site, p, nuevo in plan:
|
||||||
|
fd, ppath = tempfile.mkstemp(suffix=".json")
|
||||||
|
with os.fdopen(fd, "w") as f:
|
||||||
|
json.dump({"codeinjection_head": nuevo}, f, ensure_ascii=False)
|
||||||
|
r = sh(f'{SITIOS[site]["cli"]} post update {p["id"]} --from-json "{ppath}"')
|
||||||
|
os.unlink(ppath)
|
||||||
|
ok = "Slug:" in r.stdout
|
||||||
|
fallos += 0 if ok else 1
|
||||||
|
print(f" {'✓' if ok else '✗'} [{site}] {p['slug'][:56]}")
|
||||||
|
if not ok:
|
||||||
|
print(f" {(r.stdout + r.stderr)[:200]}")
|
||||||
|
print(f"\n{len(plan) - fallos}/{len(plan)} aplicados")
|
||||||
|
return 1 if fallos else 0
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_comprueba(a):
|
||||||
|
"""La verdad está en la página pública: que Ghost lo guarde no basta."""
|
||||||
|
import urllib.request
|
||||||
|
parejas = lee_parejas()
|
||||||
|
malos = 0
|
||||||
|
for slug_en, slug_es in parejas:
|
||||||
|
for site, slug in (("en", slug_en), ("es", slug_es)):
|
||||||
|
url = f"{SITIOS[site]['base']}/{slug}/"
|
||||||
|
try:
|
||||||
|
req = urllib.request.Request(url, headers={"User-Agent": "seo-hreflang/1.0"})
|
||||||
|
html = urllib.request.urlopen(req, timeout=30).read().decode("utf8", "ignore")
|
||||||
|
except Exception as e:
|
||||||
|
print(f" ✗ [{site}] {slug[:48]}: {e}")
|
||||||
|
malos += 1
|
||||||
|
continue
|
||||||
|
enc = dict(re.findall(
|
||||||
|
r'<link[^>]+hreflang="([^"]+)"[^>]+href="([^"]+)"', html))
|
||||||
|
# No basta con que HAYA hreflang: un par cruzado (el ES apuntando al
|
||||||
|
# EN equivocado) declararía las dos versiones y pasaría igual. Se
|
||||||
|
# comprueba que las URLs sean EXACTAMENTE las de esta pareja.
|
||||||
|
esperado = {"en": f"{SITIOS['en']['base']}/{slug_en}/",
|
||||||
|
"es": f"{SITIOS['es']['base']}/{slug_es}/"}
|
||||||
|
esperado["x-default"] = esperado["en"]
|
||||||
|
mal = {k: (enc.get(k), v) for k, v in esperado.items() if enc.get(k) != v}
|
||||||
|
malos += 0 if not mal else 1
|
||||||
|
print(f" {'✓' if not mal else '✗'} [{site}] {slug[:52]}")
|
||||||
|
for k, (tiene, debe) in mal.items():
|
||||||
|
print(f" {k}: dice {tiene} — debería ser {debe}")
|
||||||
|
print(f"\n{'✓ todas las páginas declaran las dos versiones' if not malos else f'✗ {malos} con problema'}")
|
||||||
|
return 1 if malos else 0
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
ap = argparse.ArgumentParser(description=__doc__,
|
||||||
|
formatter_class=argparse.RawDescriptionHelpFormatter)
|
||||||
|
sub = ap.add_subparsers(dest="cmd", required=True)
|
||||||
|
for nombre, fn in (("plan", cmd_plan), ("aplicar", cmd_aplicar),
|
||||||
|
("comprueba", cmd_comprueba)):
|
||||||
|
sub.add_parser(nombre).set_defaults(func=fn)
|
||||||
|
a = ap.parse_args()
|
||||||
|
return a.func(a) or 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
Reference in New Issue
Block a user