infografia_brief: slug y sitio opcionales

Escribir un slug de 40 caracteres y acordarse del --site era casi toda la
fricción de la herramienta, y el caso normal es siempre el mismo: quiero la
infografía del borrador que acabo de terminar.

    infografia            → el borrador más recién tocado de los dos sitios
    infografia --lista    → los enumera, con si tienen imagen ya o no
    infografia <slug>     → busca en los dos sitios; los slugs no se repiten
                            entre blogs, así que no hay ambigüedad posible
    infografia <URL>      → el sitio sale del dominio (pegada del navegador)

--site sigue existiendo, ahora solo para desambiguar o para acotar la búsqueda
de borradores a un blog.

Solo se ofrecen borradores al elegir automáticamente: un post publicado ya
tiene su imagen, y pedir el guion de uno publicado es la excepción, para la que
está pasar el slug o la URL a mano.

_post_list vuelca a fichero y no a PIPE, copiando el patrón de seo_watch y
remate_watch: un PIPE se trunca a 64 KB en silencio y con --formats html los
cuerpos son largos.

Verificado: las dos formas de URL (EN en www, ES en apex) resuelven su sitio,
el slug suelto encuentra el blog correcto, el slug inexistente sale con un
error claro, y la selección automática coge el más reciente y avisa de cuántos
más hay. El caso "no hay borradores" sale limpio en vez de reventar.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
ChemaVX
2026-07-27 14:28:32 +00:00
co-authored by Claude Opus 5
parent 8d1ffa4f6d
commit 0580357333
+108 -12
View File
@@ -40,9 +40,9 @@ TEMPLATE = os.path.join(os.path.dirname(os.path.abspath(__file__)), "infografia_
# Mismos dos sitios que el resto de ~/seo-tools. El idioma NO se adivina del # Mismos dos sitios que el resto de ~/seo-tools. El idioma NO se adivina del
# texto: lo fija el sitio, que es un dato duro y no se equivoca nunca. # texto: lo fija el sitio, que es un dato duro y no se equivoca nunca.
SITES = { SITES = {
"en": {"cli": "ghst-en", "idioma": "inglés", "en": {"cli": "ghst-en", "host": "theexclusionzone.com", "idioma": "inglés",
"fecha": "November 12, 1976", "decimal": "punto"}, "fecha": "November 12, 1976", "decimal": "punto"},
"es": {"cli": "ghst-es", "idioma": "español", "es": {"cli": "ghst-es", "host": "zonadeexclusion.com", "idioma": "español",
"fecha": "12 de noviembre de 1976", "decimal": "coma"}, "fecha": "12 de noviembre de 1976", "decimal": "coma"},
} }
@@ -85,21 +85,109 @@ def sh(cmd, timeout=600):
return subprocess.run(cmd, shell=True, capture_output=True, text=True, timeout=timeout) return subprocess.run(cmd, shell=True, capture_output=True, text=True, timeout=timeout)
def fetch(slug, cli): def _post_list(cli, extra="", timeout=300):
"""Baja el post por el wrapper ghst-*, igual que seo_finish.fetch.""" """Vuelca `ghst-* post list` a fichero y lo parsea.
A fichero y no a PIPE, copiando el patrón de seo_watch/remate_watch: un PIPE
se trunca a 64 KB en silencio, y con --formats html los cuerpos son largos.
"""
fd, path = tempfile.mkstemp(suffix=".json", prefix="infografia_") fd, path = tempfile.mkstemp(suffix=".json", prefix="infografia_")
os.close(fd) os.close(fd)
try: try:
sh(f"{cli} post list --limit 100 --formats html --json > {path}") r = sh(f"{cli} post list --limit all {extra} --json > {path}", timeout=timeout)
if r.returncode != 0:
raise RuntimeError(f"{cli} post list falló: {r.stderr.strip()[:200]}")
with open(path) as f: with open(path) as f:
data = json.load(f) data = json.load(f)
posts = data.get("posts", data) if isinstance(data, dict) else data return data.get("posts", data) if isinstance(data, dict) else data
finally: finally:
os.unlink(path) os.unlink(path)
for p in posts:
def fetch(slug, cli):
"""Baja el post por el wrapper ghst-*, igual que seo_finish.fetch."""
for p in _post_list(cli, "--formats html"):
if p.get("slug") == slug: if p.get("slug") == slug:
return p return p
sys.exit(f"ERROR: no encuentro el post '{slug}' en {cli}") return None
def candidatos(sites=("es", "en")):
"""Borradores de los dos sitios, del más recién tocado al más viejo.
Solo borradores: un post publicado ya tiene su imagen, y pedir el guion de
algo publicado es la excepción (para eso está pasar el slug a mano).
"""
out = []
for s in sites:
try:
posts = _post_list(SITES[s]["cli"],
'--filter "status:draft" --fields id,slug,title,status,feature_image,updated_at')
except Exception as e:
print(f" (aviso: no pude listar {s}: {e})")
continue
for p in posts:
if p.get("status") == "draft":
p["_site"] = s
out.append(p)
return sorted(out, key=lambda p: p.get("updated_at") or "", reverse=True)
def de_url(texto):
"""Saca (site, slug) de una URL pegada del navegador. None si no lo es."""
m = re.match(r"https?://(?:www\.)?([\w.-]+)/([\w-]+)/?", texto.strip())
if not m:
return None
host, slug = m.group(1), m.group(2)
for s, cfg in SITES.items():
if cfg["host"] in host:
return s, slug
return None
def elige(arg_slug, arg_site):
"""Resuelve qué post hay que procesar. Devuelve (site, slug).
Tres formas de pedirlo, de más cómoda a más explícita:
· sin argumentos → el borrador más recién tocado de los dos sitios
· una URL pegada → el sitio sale del dominio
· slug [--site] → si no se da el sitio, se busca en los dos
"""
if arg_slug:
url = de_url(arg_slug)
if url:
return url
if arg_site:
return arg_site, arg_slug
# Slug suelto: mirar en los dos sitios. Los slugs no se repiten entre
# blogs, así que si aparece en uno solo no hay ambigüedad posible.
hallados = [s for s in SITES if fetch(arg_slug, SITES[s]["cli"])]
if len(hallados) == 1:
return hallados[0], arg_slug
if not hallados:
sys.exit(f"ERROR: el slug '{arg_slug}' no está ni en ES ni en EN")
sys.exit(f"ERROR: '{arg_slug}' existe en {hallados}; desambigua con --site")
borradores = candidatos([arg_site] if arg_site else ("es", "en"))
if not borradores:
sys.exit("No hay borradores. Pasa un slug o una URL si quieres el guion de un publicado.")
p = borradores[0]
print(f" sin slug: cojo el borrador más reciente → [{p['_site'].upper()}] {p['title'][:60]}")
if len(borradores) > 1:
print(f" (hay {len(borradores)} borradores; `infografia --lista` para ver los demás)")
return p["_site"], p["slug"]
def cmd_lista(site):
borradores = candidatos([site] if site else ("es", "en"))
if not borradores:
print("No hay borradores en ninguno de los dos sitios.")
return
print(f"─── borradores ({len(borradores)}), del más recién tocado ───")
for p in borradores:
img = "con imagen" if p.get("feature_image") else "SIN imagen"
print(f" [{p['_site'].upper()}] {str(p.get('updated_at'))[:16].replace('T',' ')} {img:11} {p['title'][:58]}")
print(f" {p['slug']}")
def plano(html): def plano(html):
@@ -236,15 +324,23 @@ def formatea(guion):
def main(): def main():
ap = argparse.ArgumentParser(description="Guion verificado para la infografía de un post") ap = argparse.ArgumentParser(description="Guion verificado para la infografía de un post")
ap.add_argument("slug") ap.add_argument("slug", nargs="?", help="slug o URL; si se omite, el borrador más reciente")
ap.add_argument("--site", choices=("en", "es"), required=True) ap.add_argument("--site", choices=("en", "es"), help="normalmente se deduce solo")
ap.add_argument("--lista", action="store_true", help="enumerar los borradores y salir")
ap.add_argument("--out", help="guardar el prompt final aquí (además de imprimirlo)") ap.add_argument("--out", help="guardar el prompt final aquí (además de imprimirlo)")
ap.add_argument("--claude-bin", default=os.environ.get("CLAUDE_BIN", "claude")) ap.add_argument("--claude-bin", default=os.environ.get("CLAUDE_BIN", "claude"))
ap.add_argument("--guion-json", help="saltarse el modelo y usar este guion ya extraído") ap.add_argument("--guion-json", help="saltarse el modelo y usar este guion ya extraído")
a = ap.parse_args() a = ap.parse_args()
cfg = SITES[a.site] if a.lista:
post = fetch(a.slug, cfg["cli"]) return cmd_lista(a.site)
site, slug = elige(a.slug, a.site)
a.site, a.slug = site, slug
cfg = SITES[site]
post = fetch(slug, cfg["cli"])
if not post:
sys.exit(f"ERROR: no encuentro el post '{slug}' en {cfg['cli']}")
articulo = plano(post.get("html")) articulo = plano(post.get("html"))
if len(articulo.split()) < 200: if len(articulo.split()) < 200:
sys.exit(f"ERROR: el artículo tiene {len(articulo.split())} palabras; ¿seguro que es el slug bueno?") sys.exit(f"ERROR: el artículo tiene {len(articulo.split())} palabras; ¿seguro que es el slug bueno?")