seo_finish: soporte para el sitio ES (--site en|es)

El blog ES no tenía forma de llegar desde las herramientas: solo existía el
wrapper ghst-en. Creado ghst-es (gemelo, namespace zona-exclusion, token
efímero del pod, nada en disco) y parametrizado seo_finish.

Ojo al canónico, que va al revés en cada sitio: EN es www, ES es el apex.

seo_rules.SITE_HOST está fijado a EN y NO se parametriza ahí a propósito: ese
fichero se vendoriza byte a byte dentro de ResearchOwl y la CI lo verifica.
Se reasigna en tiempo de ejecución desde use_site(), que deja intacto el
contrato compartido.

Los ficheros de trabajo (imagen convertida, volcado de texto) y los backups
llevan ahora prefijo de sitio, para que un mismo slug en ambos idiomas no se
pise.

Verificado contra los dos sitios: ES detecta las 8 violaciones del borrador
nuevo (todas por dryrun, que ya no escribe SEO) y EN sigue dando 0 en el
artículo belga publicado hoy.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
ChemaVX
2026-07-20 16:06:43 +00:00
co-authored by Claude Opus 4.8
parent 8c83fb81fc
commit 34263469e4
+38 -13
View File
@@ -47,16 +47,37 @@ import seo_exceptions as X
ALT_MAX = 191 # límite duro de Ghost para feature_image_alt
WORK = os.path.expanduser("~/.cache/seo-finish")
# Los dos sitios. Ojo al canónico: EN es www, ES es el APEX — al revés.
SITES = {
"en": {"cli": "ghst-en", "host": "theexclusionzone.com",
"url": "https://www.theexclusionzone.com/"},
"es": {"cli": "ghst-es", "host": "zonadeexclusion.com",
"url": "https://zonadeexclusion.com/"},
}
def use_site(site):
"""Selecciona el sitio y adapta el motor de reglas.
seo_rules.SITE_HOST está fijado a EN y NO se puede parametrizar ahí: ese
fichero se vendoriza byte a byte dentro de ResearchOwl y la CI lo verifica.
Se reasigna aquí en tiempo de ejecución (el módulo lo lee en cada llamada),
que deja intacto el contrato compartido.
"""
cfg = SITES[site]
R.SITE_HOST = cfg["host"]
return cfg
def sh(cmd, timeout=240):
return subprocess.run(cmd, shell=True, capture_output=True, text=True, timeout=timeout)
def fetch(slug):
def fetch(slug, cli):
fd, path = tempfile.mkstemp(suffix=".json", prefix="seo_finish_")
os.close(fd)
try:
sh(f"ghst-en post list --limit 100 --formats html --json > {path}")
sh(f"{cli} post list --limit 100 --formats html --json > {path}")
with open(path) as f:
data = json.load(f)
posts = data.get("posts", data) if isinstance(data, dict) else data
@@ -65,7 +86,7 @@ def fetch(slug):
for p in posts:
if p.get("slug") == slug:
return p
sys.exit(f"ERROR: no encuentro el post '{slug}'")
sys.exit(f"ERROR: no encuentro el post '{slug}' en {cli}")
def violations_of(post):
@@ -99,11 +120,12 @@ def grab_image(url, slug):
def cmd_prep(a):
p = fetch(a.slug)
cfg = use_site(a.site)
p = fetch(a.slug, cfg["cli"])
text = re.sub(r"\s+", " ", re.sub(r"<[^>]+>", " ", p.get("html") or "")).strip()
img = grab_image(p.get("feature_image"), a.slug)
img = grab_image(p.get("feature_image"), f"{a.site}-{a.slug[:40]}")
print(f"═══ {a.slug}")
print(f"═══ [{a.site.upper()}] {a.slug}")
print(f" estado: {p.get('status')} | publica: {str(p.get('published_at'))[:10]} | {len(text.split())} palabras")
print(f" id: {p['id']}")
print("\n─── SEO actual ───")
@@ -129,7 +151,7 @@ def cmd_prep(a):
print(f" url: {p.get('feature_image')}")
print(f" local (mírala para escribir el alt): {img or 'NO DISPONIBLE'}")
body = os.path.join(WORK, f"{a.slug}.txt")
body = os.path.join(WORK, f"{a.site}-{a.slug[:40]}.txt")
os.makedirs(WORK, exist_ok=True)
with open(body, "w") as f:
f.write(text)
@@ -137,10 +159,11 @@ def cmd_prep(a):
print(f" volcado en: {body}")
print(f" primeras líneas: {text[:400]}")
print(f"\nSiguiente paso: escribe el patch JSON y aplícalo con")
print(f" seo_finish.py apply {a.slug} --from-json <patch.json>")
print(f" seo_finish.py apply {a.slug} --site {a.site} --from-json <patch.json>")
def cmd_apply(a):
cfg = use_site(a.site)
with open(a.from_json) as f:
patch = json.load(f)
@@ -157,25 +180,25 @@ def cmd_apply(a):
print(f"{e}")
sys.exit("Abortado: corrige los límites antes de aplicar.")
p = fetch(a.slug)
p = fetch(a.slug, cfg["cli"])
# Backup de los campos que vamos a tocar, con el mismo patrón que link-batch-backup.
import datetime
bdir = os.path.expanduser("~/link-batch-backup")
os.makedirs(bdir, exist_ok=True)
ts = datetime.datetime.now().strftime("%Y%m%d-%H%M%S")
bpath = os.path.join(bdir, f"{a.slug}-seofinish-pristine-{ts}.json")
bpath = os.path.join(bdir, f"{a.site}-{a.slug[:40]}-seofinish-pristine-{ts}.json")
with open(bpath, "w") as f:
json.dump({"id": p["id"], "slug": a.slug,
**{k: p.get(k) for k in patch}}, f, ensure_ascii=False, indent=1)
print(f"backup: {os.path.basename(bpath)}")
r = sh(f'ghst-en post update {p["id"]} --from-json "{a.from_json}"')
r = sh(f'{cfg["cli"]} post update {p["id"]} --from-json "{a.from_json}"')
if "Slug:" not in r.stdout:
print(r.stdout, r.stderr)
sys.exit("ghst-en no confirmó la actualización")
sys.exit(f"{cfg['cli']} no confirmó la actualización")
print("✓ aplicado")
after = fetch(a.slug)
after = fetch(a.slug, cfg["cli"])
viol = violations_of(after)
if viol:
print(f"\n⚠ el post SIGUE con {len(viol)} violación(es):")
@@ -191,9 +214,11 @@ def main():
sub = ap.add_subparsers(dest="cmd", required=True)
p1 = sub.add_parser("prep", help="paquete de revisión (no modifica nada)")
p1.add_argument("slug")
p1.add_argument("--site", choices=("en", "es"), default="en")
p1.set_defaults(func=cmd_prep)
p2 = sub.add_parser("apply", help="aplica un patch y re-valida")
p2.add_argument("slug")
p2.add_argument("--site", choices=("en", "es"), default="en")
p2.add_argument("--from-json", required=True)
p2.set_defaults(func=cmd_apply)
a = ap.parse_args()