Saltar al contenido

publicar_blog: cómo construí un CLI en Python para publicar artículos a WordPress desde la terminal

Arquitectura visual del flujo publicar_blog: artículo Markdown que se transforma en post programado de WordPress con imagen optimizada y distribución a redes

Cada vez que terminaba un artículo me pasaba lo mismo: abrir wp-admin, pegar el Markdown en un bloque, perder cinco minutos peleando con el editor Gutenberg, redimensionar la imagen destacada porque pesaba 3 MB, copiar la meta descripción a Yoast, marcar la fecha de publicación. Diez minutos de fricción para algo que ya estaba listo en disco.

Así nació publicar_blog: un CLI en Python que toma un archivo .md con frontmatter y lo publica en un WordPress self-hosted vía REST API. Sin Selenium, sin headless browser, sin plugin custom. Solo requests, una Application Password y un pipeline pequeño y predecible.

Este post cuenta cómo está hecho, qué decisiones de diseño tomé y por qué. El código vive en mi repo personal y lo voy a abrir en cuanto termine de limpiar credenciales.

Qué hace, en una línea

Lee un Markdown con frontmatter YAML, lo convierte a HTML compatible con Gutenberg, sube la imagen destacada (optimizándola con Pillow), resuelve categorías y tags, configura la meta de Yoast SEO, y crea el post como borrador, programado o publicado. Todo en una sola llamada:

python wp_publisher.py articulos/mi-articulo.md

Las decisiones de diseño que importan

REST API + Application Passwords, no OAuth

WordPress trae desde la versión 5.6 las Application Passwords: un mecanismo nativo de autenticación de 24 caracteres que usás como Basic Auth contra la REST API. No necesita OAuth, no necesita plugin, no necesita servidor intermedio. Generás la password en Usuarios → Editar perfil, la pegás en tu .env, y listo.

Para un proyecto personal o un sitio que vos administrás, es la opción con menos fricción posible. Si la password se filtra, la revocás desde el mismo panel y generás otra.

Markdown con frontmatter como única fuente de verdad

El frontmatter YAML del Markdown es el contrato completo del post. Todo lo que WordPress necesita está ahí:

---
title: "Mi artículo"
slug: "mi-articulo"
date: "2026-06-01 09:00:00"
status: future
categories: ["Power BI", "Tutoriales"]
tags: ["python", "automatización"]
excerpt: "Resumen corto"
featured_image: "portada.png"
featured_image_alt: "Texto alternativo"
seo:
  meta_description: "Para Yoast"
  focus_keyword: "palabra clave"
---

Esto significa que el archivo .md es autosuficiente: si lo movés a otra máquina con el mismo .env apuntando a otro WordPress, se publica igual. Y si lo abrís en VS Code, el frontmatter se renderiza ordenado.

Status future por defecto, no publish

Esto fue una decisión consciente. Cuando estás escribiendo, el modo más frecuente es programar una publicación, no tirarla en producción inmediatamente. Si te equivocás y dejás future con una fecha en el pasado, el script aborta con error claro:

❌ La fecha programada está en el pasado: 2025-01-15 09:00:00

No «convierte silenciosamente a publish» — eso es una decisión peligrosa que se hace bajo presión. Mejor frenar y obligar a que la persona decida.

El pipeline, de principio a fin

.md + frontmatter
   │
   ▼
parse_article()      ← valida title, status, fecha, categorías
   │
   ▼
process_image()      ← Pillow: WebP, quality 80, max 1200 px
   │
   ▼
upload_media()       ← POST /wp-json/wp/v2/media
   │
   ▼
resolve_categories() ← busca o crea cada categoría/tag
resolve_tags()
   │
   ▼
build_post_payload() ← arma JSON con meta de Yoast
   │
   ▼
create_post()        ← POST /wp-json/wp/v2/posts

Cada paso es una función pura o casi-pura, sin efectos colaterales raros. Si algo falla, el script aborta con mensaje accionable: 401 te dice «verifica credenciales», 403 te dice «el usuario necesita rol Author o Editor», 404 te dice «REST API deshabilitada — revisa Wordfence».

El cliente HTTP tiene retry exponencial (3 intentos) para 5xx y timeouts. Nada exótico, pero suficiente para sobrevivir a los hipos del hosting compartido.

Pillow al rescate: cómo nació la optimización de imágenes

Estoy en EasyWP con almacenamiento limitado. Cada vez que subía una foto de portada de 3-4 MB, perdía cuota rápido. Las opciones eran:

  • (A) Plan A: Cloudflare R2 + plugin FIFU para servir imágenes externas.
  • (B) Plan 0: Comprimir agresivamente del lado del cliente con Pillow antes de subir.

Empecé con el Plan 0 por simpleza. El módulo image_processor.py hace tres cosas:

  1. Convierte a WebP con quality 80 (configurable).
  2. Reescala si el lado mayor supera 1200 px.
  3. Strippea metadatos EXIF/GPS al re-guardar.

¿Cuánto comprime? La portada de este mismo artículo sirve de prueba. La pasé por el procesador antes de escribir el frontmatter:

1.0 MB  60.4 KB (94.1% menos)
1171×664  1171×664

Es decir: 94 % de ahorro sin tocar las dimensiones (la imagen ya cabía en 1200 px). En fotos reales de cámara, el ahorro suele ser mayor del 95 % porque además se reescalan.

El detalle elegante es que Pillow se ejecuta antes de client.upload_media y el archivo procesado vive en un tempdir que se limpia en el finally. El archivo original en disco queda intacto. Si querés desactivarlo para un post puntual:

python wp_publisher.py articulos/x.md --no-compress

O si querés calidad máxima para una imagen con texto pequeño:

python wp_publisher.py articulos/x.md --image-quality 95 --image-max-width 1600

Los defaults también se pueden fijar en .env con IMAGE_FORMAT, IMAGE_QUALITY e IMAGE_MAX_WIDTH.

Categorías y tags: buscar, y si no existen, crear

Esto fue un detalle que parece tonto pero ahorra mucho tiempo. La REST API de WordPress acepta categories y tags como arrays de IDs, no como strings. Así que el cliente:

  1. Busca cada nombre en la taxonomía.
  2. Si existe, usa su ID.
  3. Si no existe, lo crea (si el rol del usuario lo permite).
🏷️  Categorías resueltas: [Python: 12, Automatización: 47 (creado)]
🏷️  Tags resueltos: [python: 23, wordpress: 89 (creado)]

El log marca cuáles se crearon en esta corrida — útil para detectar errores de tipeo ("Phyton" en vez de "Python") antes de que ensucien tu taxonomía con duplicados.

Si el usuario no tiene manage_categories (rol Author), el cliente devuelve un error accionable con tres caminos: crearla manual, subir el rol a Editor, o quitarla del frontmatter.

La integración con Yoast SEO

Yoast guarda su meta description y focus keyword en _yoast_wpseo_metadesc y _yoast_wpseo_focuskw. Por defecto estos campos no se exponen vía REST, lo cual es una pesadilla bien conocida.

La solución son dos líneas de PHP en un mu-plugin:

register_post_meta('post', '_yoast_wpseo_metadesc', [
    'show_in_rest' => true,
    'single' => true,
    'type' => 'string',
    'auth_callback' => fn() => current_user_can('edit_posts'),
]);

El script envía los meta y luego verifica que se hayan guardado con un GET /posts/{id}?context=edit. Si no coinciden, emite un warning amarillo en lugar de fallar silenciosamente. Esa verificación post-creación me salvó de descubrir tres meses después que la mitad de mis artículos no tenían meta description.

Modos útiles del CLI

# Dry-run: imprime el payload exacto sin enviar nada
python wp_publisher.py articulos/x.md --dry-run

# Forzar borrador
python wp_publisher.py articulos/x.md --status draft

# Publicar ya, ignorando lo que dice el frontmatter
python wp_publisher.py articulos/x.md --status publish

# Verbose: muestra request/response y password enmascarada
python wp_publisher.py articulos/x.md -v

El --dry-run es el que más uso. Te muestra el JSON completo que viajaría, incluyendo las dimensiones post-Pillow, los IDs de categoría que resolvería, la fecha en formato ISO con timezone correcto. Si algo se ve raro, no gastaste un slot del rate limit del hosting.

Lo que decidí dejar afuera (por ahora)

Conscientemente, la v1 no hace estas cosas:

  • No actualiza posts existentes. Solo crea. Para editar, abrís el editor.
  • No sube imágenes inline del cuerpo. Solo la featured_image.
  • No soporta varios sitios. Un .env apunta a un único WP_URL.
  • No dispara el share a redes sociales. Eso depende de Jetpack Social o equivalente, configurado del lado de WordPress. Es un proyecto aparte que ya estoy montando (publicar_redes) que toca las APIs nativas de Meta y LinkedIn directamente.

Cada una de estas exclusiones está documentada en el README. La regla que me funcionó: agregar features cuando el dolor real aparezca, no antes.

Stack y dependencias

python  >= 3.10  (necesito zoneinfo del stdlib)
requests           HTTP
python-frontmatter parsing del YAML del .md
python-dotenv      cargar .env
Pillow >= 10       procesamiento de imágenes
rich               output bonito en terminal
markdown           md → html
tzdata             para Windows (zoneinfo no trae datos)

Total: ocho dependencias, todas mainstream. Cero código boilerplate. El proyecto entero son ~600 líneas de Python repartidas en cinco archivos: wp_publisher.py (CLI), wp_client.py (cliente HTTP), markdown_converter.py, image_processor.py y requirements.txt.

Lo que aprendí en el camino

Tres cosas que no estaban en mi mapa antes:

  1. Windows + emojis + cp1252 = explosión. En la primera corrida, el script murió porque console.print("✅") rompía con un UnicodeEncodeError. Solución: forzar sys.stdout.reconfigure(encoding="utf-8") al inicio si estamos en Windows.

  2. EasyWP rate-limita agresivamente. En pruebas seguidas vi 429 cuando subía la misma imagen tres veces en un minuto. El retry exponencial amortigua, pero la conclusión real es: usá --dry-run mientras iterás.

  3. La REST API de WordPress es más completa de lo que parece. Soporta meta personalizado, taxonomías custom, programación con timezone, todo. El problema histórico es la documentación dispersa, no la API en sí.

Cómo lo uso ahora

Mi flujo actual de publicación quedó en tres pasos:

  1. Escribo el .md en VS Code con preview en vivo.
  2. Corro python wp_publisher.py articulos/x.md --dry-run y verifico el payload.
  3. Corro la versión real. El post queda programado y olvidame.

Lo que antes tardaba diez minutos ahora son veinte segundos, y la fricción desapareció. Eso es lo que más me importa de cualquier herramienta de automatización: no es el tiempo ahorrado, es dejar de sentir resistencia para sentarme a escribir.

Próximos pasos

  • Modo update (--update <post_id>) para editar posts existentes.
  • Subida de imágenes inline detectando ![](path) en el body.
  • Integración con publicar_redes para que después de publicar acá, el artículo se reformatee y se cuelgue en LinkedIn, Facebook e Instagram con formatos nativos de cada red.

Si te interesa el código o tenés un caso de uso parecido, escribime — pienso publicarlo en GitHub apenas termine de aislar las credenciales del historial. Y si vos también automatizaste tu publicación, contame qué herramientas usás: siempre hay un detalle que no vi.

Comentarios

Deja una respuesta

Tu dirección de correo electrónico no será publicada. Los campos obligatorios están marcados con *