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:
- Convierte a WebP con quality 80 (configurable).
- Reescala si el lado mayor supera 1200 px.
- 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:
- Busca cada nombre en la taxonomía.
- Si existe, usa su ID.
- 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
.envapunta a un únicoWP_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:
-
Windows + emojis + cp1252 = explosión. En la primera corrida, el script murió porque
console.print("✅")rompía con unUnicodeEncodeError. Solución: forzarsys.stdout.reconfigure(encoding="utf-8")al inicio si estamos en Windows. -
EasyWP rate-limita agresivamente. En pruebas seguidas vi
429cuando subía la misma imagen tres veces en un minuto. El retry exponencial amortigua, pero la conclusión real es: usá--dry-runmientras iterás. -
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:
- Escribo el
.mden VS Code con preview en vivo. - Corro
python wp_publisher.py articulos/x.md --dry-runy verifico el payload. - 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
en el body. - Integración con
publicar_redespara 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.

Deja una respuesta