Files
chemavx-seo-tools/seo_link.py
T
ChemaVXandClaude Opus 4.8 d381c0225c seo_watch/seo_link: dos fallos que se manifestaban callándose
1) El corpus se pedía con --limit 100. Con 40 posts en EN y 29 en ES sobraba,
   pero a 2 por semana habría cruzado los 100 hacia 2027 y se habría truncado
   EN SILENCIO. Y un corpus truncado no da error: da falsos «enlaces rotos»
   contra los posts que faltan, que es peor que no mirar. Pasa a --limit all
   y, sobre todo, se comprueba el número contra meta.pagination.total: eso es
   lo que convierte un fallo mudo en uno que se oye. En seo_link el truncado
   era aún peor, porque haría rechazar como inexistente un destino que sí
   está.

2) check_sitemap daba VERDE cuando curl fallaba: 0 URLs descargadas son 0
   problemas encontrados, y el informe decía « sin hallazgos». O sea que
   justo el rato en que el sitio está caído era cuando este check se callaba.
   Un sitemap vacío pasa a ser hallazgo con mensaje propio; en un blog con
   29-40 artículos nunca lo está legítimamente.

Probado con casos que DEBEN fallar: corpus recortado a 3 de 40 (revienta y lo
dice), corpus completo (pasa limpio), y sitemap vacío (hallazgo, informe claro
y notificaría). Y en real contra los dos blogs: EN 40 posts / 33 URLs, ES 29 /
31, ambos sin hallazgos.

No toca seo_rules.py, así que el vendor-sync con ResearchOwl sigue en verde.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-21 16:01:28 +00:00

273 lines
11 KiB
Python

#!/usr/bin/env python3
"""
Tool F — inserta enlaces internos en el CUERPO de un post (EN o ES, Ghost).
Es la pieza que faltaba: el resto de herramientas leen `html` (que Ghost
renderiza sea cual sea el formato de origen) y por eso les da igual el formato.
Escribir en el cuerpo, no: hay que tocar la fuente, y la fuente viene en tres
sabores distintos según cuándo y cómo se creó el post.
1. lexical con UN bloque `html` — los 29 del ES y casi todos los del EN
2. lexical con nodos NATIVOS — los dos forenses del EN (roswell-341,
yellow-sea); el texto vive en nodos
`extended-text` dentro de `paragraph`
3. mobiledoc con tarjetas `html` — lo que genera ResearchOwl HOY en los
borradores nuevos (descubierto el
2026-07-21 con el Canarias de prueba)
Comprobaciones ANTES de escribir (todas son cicatrices de errores reales):
- el slug destino existe en el corpus de ESE sitio (no del otro)
- el destino está publicado; si está programado avisa de enlace PREMATURO
(daría 404 en la ventana intermedia — caso Wilson-Davis → MJ-12)
- la URL se construye con el canónico del sitio: EN es www, ES es el APEX
- el anchor aparece en la prosa y NO dentro de un <a> ya existente
(casi metemos un <a> anidado en Levelland con «Project Blue Book»)
- tras la edición: anclas balanceadas y ninguna anidada
Uso:
python3 seo_link.py <slug> --site es --link "incidente de Roswell=roswell-1947-..."
python3 seo_link.py <slug> --site es --link "A=slug-a" --link "B=slug-b" --apply
Sin --apply no escribe: imprime lo que haría con el contexto de cada inserción.
"""
import argparse
import copy
import datetime
import json
import os
import re
import subprocess
import sys
import tempfile
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
SITES = {
"en": {"cli": "ghst-en", "base": "https://www.theexclusionzone.com"},
"es": {"cli": "ghst-es", "base": "https://zonadeexclusion.com"},
}
BDIR = os.path.expanduser("~/link-batch-backup")
# Esquema exacto de los nodos lexical nativos, copiado de un enlace real del
# corpus. No inventar campos: Ghost valida y un nodo mal formado se pierde.
TEXT_NODE = {"detail": 0, "format": 0, "mode": "normal", "style": "",
"text": "", "type": "extended-text", "version": 1}
LINK_NODE = {"children": [], "direction": "ltr", "format": "", "indent": 0,
"type": "link", "version": 1, "rel": None, "target": None,
"title": None, "url": ""}
def sh(cmd, timeout=240):
return subprocess.run(cmd, shell=True, capture_output=True, text=True, timeout=timeout)
def fetch_corpus(cli):
"""Corpus completo (published + scheduled). A fichero: un PIPE se trunca.
`--limit all` + comprobación del total (ver seo_watch.fetch_posts). Aquí un
corpus truncado es peor todavía que en el vigilante: haría fallar el
«¿existe el destino?» y rechazaría un enlace perfectamente válido, o peor,
daría por inexistente un post que sí está."""
fd, path = tempfile.mkstemp(suffix=".json")
os.close(fd)
try:
sh(f"{cli} post list --limit all --formats lexical,mobiledoc,html --json > {path}")
with open(path) as f:
d = json.load(f)
posts = d.get("posts", d) if isinstance(d, dict) else d
total = ((d.get("meta") or {}).get("pagination") or {}).get("total") \
if isinstance(d, dict) else None
if total is not None and len(posts) != total:
sys.exit(f"✗ corpus truncado: {len(posts)} de {total} posts — abortado "
f"sin escribir (el chequeo de destinos sería mentira)")
return posts
finally:
os.unlink(path)
# ---------- detección de formato ----------
def body_format(post):
lex = post.get("lexical")
if lex:
kids = json.loads(lex)["root"]["children"]
if len(kids) == 1 and kids[0].get("type") == "html":
return "lexical-html"
return "lexical-nativo"
if post.get("mobiledoc"):
return "mobiledoc"
return None
# ---------- inserción en HTML (vale para lexical-html y mobiledoc) ----------
def link_in_html(html, anchor, url):
"""Primer <p> con el anchor FUERA de cualquier <a>. -> (html, ok, contexto)"""
hecho = [False]
ctx = [""]
def en_parrafo(m):
if hecho[0]:
return m.group(0)
trozos = re.split(r"(<a\b.*?</a>)", m.group(2), flags=re.S)
for i, tr in enumerate(trozos):
if tr.startswith("<a"):
continue
mm = re.search(r"(?<![\w-])" + re.escape(anchor) + r"(?![\w-])", tr)
if not mm:
continue
trozos[i] = tr[:mm.start()] + f'<a href="{url}">{anchor}</a>' + tr[mm.end():]
a, b = max(0, mm.start() - 55), mm.end() + 55
ctx[0] = re.sub(r"\s+", " ", re.sub(r"<[^>]+>", "", tr[a:b]))
hecho[0] = True
break
return m.group(1) + "".join(trozos) + m.group(3)
out = re.sub(r"(<p>)(.*?)(</p>)", en_parrafo, html, flags=re.S)
return out, hecho[0], ctx[0]
# ---------- inserción en lexical nativo ----------
def link_in_lexical_nodes(root_children, anchor, url):
"""Parte el nodo de texto en (antes | link | después). Solo mira los hijos
DIRECTOS del párrafo, así que un texto que ya viva dentro de un `link`
nunca se toca — que es justo lo que evita el anidamiento."""
for parrafo in root_children:
if parrafo.get("type") != "paragraph":
continue
hijos = parrafo.get("children") or []
for i, nodo in enumerate(hijos):
if nodo.get("type") != "extended-text":
continue
txt = nodo.get("text") or ""
mm = re.search(r"(?<![\w-])" + re.escape(anchor) + r"(?![\w-])", txt)
if not mm:
continue
antes, despues = txt[:mm.start()], txt[mm.end():]
enlace = copy.deepcopy(LINK_NODE)
enlace["url"] = url
hijo = copy.deepcopy(TEXT_NODE)
hijo["text"] = anchor
hijo["format"] = nodo.get("format", 0)
enlace["children"] = [hijo]
nuevos = []
if antes:
n = copy.deepcopy(nodo)
n["text"] = antes
nuevos.append(n)
nuevos.append(enlace)
if despues:
n = copy.deepcopy(nodo)
n["text"] = despues
nuevos.append(n)
parrafo["children"] = hijos[:i] + nuevos + hijos[i + 1:]
a, b = max(0, mm.start() - 55), mm.end() + 55
return True, re.sub(r"\s+", " ", txt[a:b])
return False, ""
# ---------- salvaguardas ----------
def revisar_html(html, slug):
if re.search(r"<a [^>]*>(?:(?!</a>).)*<a ", html, re.S):
sys.exit(f"✗ {slug}: ancla anidada — abortado sin escribir")
if html.count("<a ") != html.count("</a>"):
sys.exit(f"✗ {slug}: <a> descuadrados — abortado sin escribir")
def main():
ap = argparse.ArgumentParser(description="Enlaces internos en el cuerpo (Tool F)")
ap.add_argument("slug")
ap.add_argument("--site", choices=("en", "es"), required=True)
ap.add_argument("--link", action="append", required=True,
metavar="ANCHOR=slug-destino",
help="texto del anchor y slug al que apunta; repetible")
ap.add_argument("--apply", action="store_true")
a = ap.parse_args()
cfg = SITES[a.site]
corpus = fetch_corpus(cfg["cli"])
by = {p["slug"]: p for p in corpus}
if a.slug not in by:
sys.exit(f"✗ «{a.slug}» no está en el corpus de [{a.site.upper()}] "
f"— ¿es un slug del otro sitio?")
post = by[a.slug]
fmt = body_format(post)
if not fmt:
sys.exit(f"✗ {a.slug}: sin cuerpo legible (ni lexical ni mobiledoc)")
print(f"── {a.slug[:56]} [{post.get('status')}] formato: {fmt}")
pares = []
for spec in a.link:
if "=" not in spec:
sys.exit(f"✗ --link mal formado: {spec!r} (falta «=»)")
anchor, destino = spec.split("=", 1)
anchor, destino = anchor.strip(), destino.strip().strip("/")
if destino not in by:
sys.exit(f"✗ destino «{destino}» no existe en [{a.site.upper()}]")
if destino == a.slug:
sys.exit(f"✗ «{destino}» se enlazaría a sí mismo")
tgt = by[destino]
if tgt.get("status") != "published":
print(f" ⚠ PREMATURO: «{destino}» está en estado "
f"{tgt.get('status')} → 404 hasta que se publique")
pares.append((anchor, f'{cfg["base"]}/{destino}/'))
# --- inserción según formato ---
if fmt == "lexical-nativo":
lex = json.loads(post["lexical"])
kids = lex["root"]["children"]
for anchor, url in pares:
ok, ctx = link_in_lexical_nodes(kids, anchor, url)
print(f" {'+' if ok else '⚠'} «{anchor}» → {url.split('/')[-2][:34]}"
+ (f"\n{ctx}…" if ok else " (no aparece en la prosa)"))
campo, valor = "lexical", json.dumps(lex, ensure_ascii=False)
else:
if fmt == "lexical-html":
doc = json.loads(post["lexical"])
trozo = doc["root"]["children"][0]
get_html, set_html = (lambda: trozo["html"]), (lambda h: trozo.__setitem__("html", h))
else:
doc = json.loads(post["mobiledoc"])
cards = [c for c in doc.get("cards", []) if c and c[0] == "html"]
if not cards:
sys.exit(f"✗ {a.slug}: mobiledoc sin tarjetas html")
tarjeta = cards[0][1]
get_html, set_html = (lambda: tarjeta["html"]), (lambda h: tarjeta.__setitem__("html", h))
html = get_html()
for anchor, url in pares:
html, ok, ctx = link_in_html(html, anchor, url)
print(f" {'+' if ok else '⚠'} «{anchor}» → {url.split('/')[-2][:34]}"
+ (f"\n{ctx}…" if ok else " (no aparece en la prosa)"))
revisar_html(html, a.slug)
set_html(html)
campo = "lexical" if fmt == "lexical-html" else "mobiledoc"
valor = json.dumps(doc, ensure_ascii=False)
if not a.apply:
print("\n(dry-run: no se ha escrito nada — repite con --apply)")
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"{a.site}-{a.slug[:40]}-seolink-pristine-{ts}.json")
with open(bpath, "w") as f:
json.dump({"id": post["id"], "slug": a.slug, campo: post.get(campo)},
f, ensure_ascii=False)
fd, ppath = tempfile.mkstemp(suffix=".json")
with os.fdopen(fd, "w") as f:
json.dump({campo: valor}, f, ensure_ascii=False)
r = sh(f'{cfg["cli"]} post update {post["id"]} --from-json "{ppath}"')
os.unlink(ppath)
if "Slug:" not in r.stdout:
print(r.stdout, r.stderr)
sys.exit(f"✗ {a.slug}: {cfg['cli']} no confirmó la actualización")
print(f"✓ aplicado (backup {os.path.basename(bpath)})")
return 0
if __name__ == "__main__":
sys.exit(main())