Programación y enlaces más inteligentes para mi publicador cruzado de Postiz
Hace un tiempo conecté mi gestor de blog (una pequeña app en Rails 8) con Postiz, un programador social autoalojado, para poder publicar en Bluesky, Mastodon, LinkedIn y el resto con un solo botón. Funcionaba, pero era tosco: publicaba de inmediato, y la descripción era solo el título más la URL canónica. Sin imagen, sin descripción, sin forma de decir “en realidad, envía esto mañana por la mañana.” Este es el registro de cómo se arregló eso, incluyendo dos errores que solo aparecieron al hacer clic en la interfaz real.
Qué estaba tratando de lograr
Tres cosas:
- Programar una publicación para un momento futuro en vez de publicar siempre de inmediato.
- Enriquecer la descripción: incluir el resumen de la publicación y adjuntar una imagen en miniatura.
- Poder elegir qué URL se comparte, ya que la mayoría de las publicaciones también existen en Medium y Dev.to.
Leer la API antes de escribir código
La API pública de Postiz no es enorme, pero no quería adivinar la forma de las solicitudes. Fijé mi lectura a la versión exacta que corre mi instancia (v2.21.8) en vez de lo que sea que tenga main hoy, y eso dio resultado de inmediato: el primer archivo de controlador que abrí era el equivocado y no tenía ninguna ruta de carga. El controlador real de la API pública tenía lo que hacía falta:
POST /upload-from-urlrecibe un JSON{ "url": "..." }y devuelve un objeto de medio conidypath. Es un cuerpo JSON simple, así que el cliente HTTP existente funcionó tal cual, sin manejo multipart.POST /postsadjunta el medio por canal comovalue[].image: [{ id, path }].typeesnow | schedule | draft, condatecomo la hora programada.GET /find-slot/:iddevuelve el próximo espacio libre para un canal:{ "date": "..." }.
Dos minutos leyendo el código fuente real evitaron construir un cargador multipart que no hacía falta.
Qué se construyó
Programar fue la parte fácil una vez que el payload quedó claro. El publicador ahora acepta un scheduled_at opcional, si es una fecha futura, envía type: "schedule" con esa fecha, y si no, vuelve a publicar de inmediato. El selector recibió un campo de fecha y hora, y un botón “Sugerir próximo espacio libre” que llama a find-slot para los canales marcados y completa el campo.
Para la miniatura, se sube la imagen principal que ya tiene la publicación (una foto de Pexels ya almacenada) mediante upload-from-url y se adjunta el id devuelto a cada canal. La carga se dejó como best-effort a propósito: si falla, la publicación sale igual como texto en vez de que todo se caiga porque una imagen devolvió un 500.
La descripción también entra en el resumen, pero X y Bluesky tienen límites cortos, así que todo se trunca a unos 280 caracteres, reservando espacio primero para el título y la URL, y recortando la descripción para que entre.
El error de zona horaria que casi se publicó
Este es el que me alegra haber detectado antes de fusionar. El selector de fecha y hora del navegador entrega una cadena de “reloj de pared” sin zona horaria: 2026-06-10T09:00. La app en Rails corría en UTC (el valor por defecto). Así que al elegir las 9:00 a.m., el servidor la interpretaba felizmente como las 9:00 UTC, que son las 3:00 a.m. en mi zona horaria. La publicación quedaría programada con seis horas de diferencia, y lo peor es que la etiqueta “Scheduled for” se mostraba en ese mismo UTC, así que parecía consistente. Nada en pantalla indicaba el error hasta que la publicación saliera a la hora equivocada.
Esta es una app de un solo usuario que corre desde una sola zona horaria, así que la solución fue una línea: fijar config.time_zone a la zona real. Ahora el selector, el parseo y la visualización coinciden, y la base de datos sigue guardando UTC por debajo. Si fuera una app multiusuario habría que hacer el manejo correcto del offset del navegador, pero no lo es, así que no hizo falta.
El error que me hizo decir “no funcionó”
El selector de enlaces necesitaba datos reales para que probarlo tuviera sentido, así que escribí una pequeña tarea de rake para copiar las URLs de Medium de la base de datos de producción a la local, emparejadas por blog y slug. Producción es SQLite sobre un volumen de Docker, así que “leer de producción” en realidad significa “traer una instantánea consistente y leer de ahí.” Usé VACUUM INTO para generar la instantánea (sin necesitar el CLI de sqlite3 dentro del contenedor), la bajé con scp, y corrí la importación. Reportó 69 URLs copiadas.
Después cargué la interfaz y… nada. Todas las publicaciones seguían diciendo “Not imported” para Medium.
Casi empecé a adivinar, pero me obligué a leer la vista en su lugar. La sección de Medium de la página depende de medium_status (un enum: not imported / draft / published), no de medium_url. La URL se había copiado, pero el estado quedó en “not imported,” así que para la interfaz nada había cambiado. La URL técnicamente estaba ahí, solo que invisible.
La solución fue copiar el estado de sindicación, no solo la URL: medium_url y medium_status juntos (más un par de campos relacionados). Revisé producción y, efectivamente, las 69 publicaciones estaban en published. Volví a correr la importación y las insignias se encendieron. Obvio en retrospectiva, pero un buen recordatorio de que “el dato está en la columna” y “la interfaz lo muestra” son dos afirmaciones distintas.
Qué haría distinto y qué sigue
Si volviera a hacer la copia de datos, pensaría en qué lee realmente la interfaz antes de decidir qué columnas copiar, en vez de fijarme solo en el campo que aparecía en el nombre de la tarea. El tema de la zona horaria lo detectaría antes con solo preguntar “¿en qué zona horaria está esta cadena?” en el momento en que una fecha cruza la frontera entre navegador y servidor.
Lo siguiente es el receptor de webhook que cambia una publicación de “scheduled” a “posted” una vez que Postiz realmente la publica, para que las URLs publicadas por canal vuelvan a la app. Por ahora puedo programar, enriquecer y elegir el enlace, y las publicaciones salen donde y cuando quiero.
Lecturas relacionadas
Add a feature, or move a responsibility?
Adding Postiz social cross-posting looked done until a blunt question exposed a double-post bug, and a full audit of every posting path in the app found two more like it.
El webhook de Postiz que no pude construir, y el sondeo en su lugar
Un spike que terminó en "no construir esto": rechazo por IP privada, un payload vacío sin autenticación, y la API de lectura que tenía todo lo que le faltaba al push.
El último publicador nativo: terminando la consolidación de Postiz
La integración nativa de Medium era una pieza de museo. Un puente de navegador, un artículo cuyo cuerpo es una URL, y menos 1.603 líneas.