--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 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"(?]*>(?:(?!