#!/usr/bin/env python3 """ Tool E — "remate SEO": cierra el SEO de un post DESPUÉS de la revisión editorial y de elegir la imagen destacada. Existe porque el SEO es DERIVADO del artículo: si ResearchOwl lo genera al crear el borrador y luego la revisión editorial cambia el texto, el SEO queda obsoleto (meta description describiendo un texto que ya no existe, excerpt descuadrado). La solución no es parchear: es re-derivarlo del texto FINAL. Y el alt-text solo puede escribirse aquí, porque en el momento de generar el artículo la imagen destacada todavía no se ha elegido (por eso autofill.py lo deja vacío). Dos modos: seo_finish.py prep Prepara el "paquete de revisión": descarga la imagen destacada y la convierte a PNG (Ghost sirve webp, que no todos los visores leen), vuelca el texto final del artículo y el SEO actual con sus longitudes, y lista las violaciones vigentes. No modifica NADA. seo_finish.py apply --from-json Aplica el patch vía ghst-en, y luego RE-VALIDA con el motor de reglas. Si el resultado sigue incumpliendo, lo dice (no se calla). El criterio (escribir la meta, describir la imagen) lo pone quien invoca: un humano o Claude vía hermes. Este script hace lo mecánico y lo verifica. Gotchas codificados aquí (aprendidos a base de 422s el 2026-07-18): * Ghost corta feature_image_alt en 191 caracteres → 422 sin explicar por qué. * meta_description tiene su propio límite en seo_rules (145). * Los posts alternan bloques HTML y nodos lexical nativos: para tocar el CUERPO hay que mirar cuál es. Este script solo toca CAMPOS, así que no le afecta — pero que conste para quien venga a editar el cuerpo. """ import argparse import json import os import re import subprocess import sys import tempfile sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) import seo_rules as R 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, cli): fd, path = tempfile.mkstemp(suffix=".json", prefix="seo_finish_") os.close(fd) try: 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 finally: os.unlink(path) for p in posts: if p.get("slug") == slug: return p sys.exit(f"ERROR: no encuentro el post '{slug}' en {cli}") def violations_of(post, site): out = [] for v in R.check_post(post): if v.severity == R.INFO or X.accepted_reason(v.rule, post.get("slug"), site): continue out.append(v) return out def grab_image(url, slug): """Descarga la imagen destacada y devuelve una ruta PNG visualizable.""" if not url: return None os.makedirs(WORK, exist_ok=True) raw = os.path.join(WORK, f"{slug}.raw") r = sh(f'curl -s --max-time 40 -o "{raw}" "{url}"') if not os.path.exists(raw) or os.path.getsize(raw) < 1000: return None png = os.path.join(WORK, f"{slug}.png") try: from PIL import Image im = Image.open(raw).convert("RGB") im.thumbnail((1200, 1200)) # suficiente para leer una infografía im.save(png) return png except Exception as e: print(f" (no pude convertir la imagen: {e})") return raw def cmd_prep(a): 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"), f"{a.site}-{a.slug[:40]}") 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 ───") for f, lim in (("meta_title", R.META_TITLE_MAX), ("meta_description", R.META_DESC_MAX), ("feature_image_alt", ALT_MAX), ("custom_excerpt", None), ("og_title", None), ("og_description", None)): v = (p.get(f) or "").strip() mark = "" if lim and len(v) > lim: mark = f" ⚠ PASA DE {lim}" elif not v: mark = " ⚠ VACÍO" print(f" {f:20} [{len(v):3}]{mark}") if v: print(f" {v[:150]}") viol = violations_of(p, a.site) print(f"\n─── violaciones vigentes: {len(viol)} ───") for v in viol: print(f" [{v.severity}] {v.rule}: {v.message}") print(f"\n─── imagen destacada ───") 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.site}-{a.slug[:40]}.txt") os.makedirs(WORK, exist_ok=True) with open(body, "w") as f: f.write(text) print(f"\n─── texto final del artículo ───") 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} --site {a.site} --from-json ") def cmd_apply(a): cfg = use_site(a.site) with open(a.from_json) as f: patch = json.load(f) # Validación previa de límites duros: mejor un mensaje claro que un 422 mudo. errs = [] if len((patch.get("feature_image_alt") or "")) > ALT_MAX: errs.append(f"feature_image_alt {len(patch['feature_image_alt'])} car. > {ALT_MAX} (Ghost devolverá 422)") if len((patch.get("meta_description") or "")) > R.META_DESC_MAX: errs.append(f"meta_description {len(patch['meta_description'])} car. > {R.META_DESC_MAX}") if len((patch.get("meta_title") or "")) > R.META_TITLE_MAX: errs.append(f"meta_title {len(patch['meta_title'])} car. > {R.META_TITLE_MAX}") if errs: for e in errs: print(f"✗ {e}") sys.exit("Abortado: corrige los límites antes de aplicar.") 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.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'{cfg["cli"]} post update {p["id"]} --from-json "{a.from_json}"') if "Slug:" not in r.stdout: print(r.stdout, r.stderr) sys.exit(f"✗ {cfg['cli']} no confirmó la actualización") print("✓ aplicado") after = fetch(a.slug, cfg["cli"]) viol = violations_of(after, a.site) if viol: print(f"\n⚠ el post SIGUE con {len(viol)} violación(es):") for v in viol: print(f" [{v.severity}] {v.rule}: {v.message}") return 1 print("✓ verificado: sin violaciones accionables") return 0 def main(): ap = argparse.ArgumentParser(description="Remate SEO post-revisión editorial") 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() return a.func(a) or 0 if __name__ == "__main__": sys.exit(main())