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:
+108
-12
@@ -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?")
|
||||||
|
|||||||
Reference in New Issue
Block a user