From 0580357333888d38d4c1f81bede9ee74aeebc694 Mon Sep 17 00:00:00 2001 From: ChemaVX Date: Mon, 27 Jul 2026 14:28:32 +0000 Subject: [PATCH] infografia_brief: slug y sitio opcionales MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 → busca en los dos sitios; los slugs no se repiten entre blogs, así que no hay ambigüedad posible infografia → 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 --- infografia_brief.py | 120 +++++++++++++++++++++++++++++++++++++++----- 1 file changed, 108 insertions(+), 12 deletions(-) diff --git a/infografia_brief.py b/infografia_brief.py index 62d75fa..ab1e21f 100755 --- a/infografia_brief.py +++ b/infografia_brief.py @@ -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 # texto: lo fija el sitio, que es un dato duro y no se equivoca nunca. SITES = { - "en": {"cli": "ghst-en", "idioma": "inglés", + "en": {"cli": "ghst-en", "host": "theexclusionzone.com", "idioma": "inglés", "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"}, } @@ -85,21 +85,109 @@ def sh(cmd, timeout=600): return subprocess.run(cmd, shell=True, capture_output=True, text=True, timeout=timeout) -def fetch(slug, cli): - """Baja el post por el wrapper ghst-*, igual que seo_finish.fetch.""" +def _post_list(cli, extra="", timeout=300): + """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_") os.close(fd) 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: 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: 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: 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): @@ -236,15 +324,23 @@ def formatea(guion): def main(): ap = argparse.ArgumentParser(description="Guion verificado para la infografía de un post") - ap.add_argument("slug") - ap.add_argument("--site", choices=("en", "es"), required=True) + ap.add_argument("slug", nargs="?", help="slug o URL; si se omite, el borrador más reciente") + 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("--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") a = ap.parse_args() - cfg = SITES[a.site] - post = fetch(a.slug, cfg["cli"]) + if a.lista: + 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")) if len(articulo.split()) < 200: sys.exit(f"ERROR: el artículo tiene {len(articulo.split())} palabras; ¿seguro que es el slug bueno?")