From 162f8032f3e9a4a4100f525afa95c555549a768f Mon Sep 17 00:00:00 2001 From: ChemaVX Date: Mon, 20 Jul 2026 10:14:52 +0000 Subject: [PATCH] =?UTF-8?q?seo=5Ffinish:=20remate=20SEO=20posterior=20a=20?= =?UTF-8?q?la=20revisi=C3=B3n=20editorial=20(Tool=20E)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit El SEO es DERIVADO del artículo. Hoy ResearchOwl lo genera al crear el borrador, pero la revisión editorial cambia el texto después → el SEO queda obsoleto y hay que rehacerlo a mano en cada post. La solución no es parchear sino re-derivarlo del texto FINAL, y ese es el hueco que cubre esta tool. Además el alt-text SOLO puede escribirse aquí: cuando autofill.py genera el SEO la imagen destacada aún no se ha elegido (de ahí su 'feature_image_alt': "" # human adds later). Ese paso humano no ocurría nunca — los 9 posts publicados entre el 29-jun y el 16-jul salieron sin alt. prep paquete de revisión: descarga la imagen destacada y la convierte a PNG (Ghost sirve webp), vuelca el texto final, lista el SEO actual con longitudes y las violaciones. READ-ONLY. apply aplica un patch vía ghst-en, con backup previo, y RE-VALIDA después (si sigue incumpliendo, lo dice). Guardas por límites duros, para convertir en error explicado lo que Ghost devuelve como 422 mudo: * feature_image_alt > 191 car. (límite de Ghost, descubierto a base de 422) * meta_description > 145 / meta_title > 60 (seo_rules) El criterio (redactar la meta, describir la imagen) lo pone quien invoca — humano o Claude vía hermes. El script hace lo mecánico y lo verifica. Co-Authored-By: Claude Opus 4.8 --- seo_finish.py | 204 ++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 204 insertions(+) create mode 100755 seo_finish.py diff --git a/seo_finish.py b/seo_finish.py new file mode 100755 index 0000000..272ab13 --- /dev/null +++ b/seo_finish.py @@ -0,0 +1,204 @@ +#!/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") + + +def sh(cmd, timeout=240): + return subprocess.run(cmd, shell=True, capture_output=True, text=True, timeout=timeout) + + +def fetch(slug): + 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}") + 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}'") + + +def violations_of(post): + out = [] + for v in R.check_post(post): + if v.severity == R.INFO or X.accepted_reason(v.rule, post.get("slug")): + 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): + p = fetch(a.slug) + text = re.sub(r"\s+", " ", re.sub(r"<[^>]+>", " ", p.get("html") or "")).strip() + img = grab_image(p.get("feature_image"), a.slug) + + print(f"═══ {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) + 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.slug}.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} --from-json ") + + +def cmd_apply(a): + 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) + # 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") + 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}"') + if "Slug:" not in r.stdout: + print(r.stdout, r.stderr) + sys.exit("✗ ghst-en no confirmó la actualización") + print("✓ aplicado") + + after = fetch(a.slug) + viol = violations_of(after) + 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.set_defaults(func=cmd_prep) + p2 = sub.add_parser("apply", help="aplica un patch y re-valida") + p2.add_argument("slug") + 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())