--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."""
+ fd, path = tempfile.mkstemp(suffix=".json")
+ os.close(fd)
+ try:
+ sh(f"{cli} post list --limit 100 --formats lexical,mobiledoc,html --json > {path}")
+ with open(path) as f:
+ d = json.load(f)
+ return d.get("posts", d) if isinstance(d, dict) else d
+ 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 con el anchor FUERA de cualquier . -> (html, ok, contexto)"""
+ hecho = [False]
+ ctx = [""]
+
+ def en_parrafo(m):
+ if hecho[0]:
+ return m.group(0)
+ trozos = re.split(r"()", m.group(2), flags=re.S)
+ for i, tr in enumerate(trozos):
+ if tr.startswith("{anchor}' + 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"(
)(.*?)(
)", 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"(?]*>(?:(?!