seo_finish: remate SEO posterior a la revisión editorial (Tool E)
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 <slug> 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 <slug> 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 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
c4dba06fb1
commit
162f8032f3
Executable
+204
@@ -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 <slug>
|
||||||
|
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 <slug> --from-json <patch.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 <patch.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())
|
||||||
Reference in New Issue
Block a user