Skip to main content
← Back to blog

Cómo construimos el pipeline de publicación: cola, retries y las APIs de LinkedIn y X

Tres plataformas, sesenta segundos. La parte fácil es generar; la difícil es que los tres POSTs lleguen sin que se rompa nada.

El post de lanzamiento prometía algo concreto: tres plataformas, sesenta segundos, un solo clic. Lo prometimos antes de tenerlo del todo. Cuando nos pusimos a construirlo, descubrimos que cada plataforma tiene una forma distinta de joderte, y que la diferencia entre "funciona en demo" y "funciona en producción" son las cinco esquinas que nadie te cuenta.

Este es el mapa de cómo está montado hoy.

Arquitectura: cola en BD, adapters por plataforma

Cuando el autor pulsa "publicar", la API del dashboard hace tres cosas:

  1. Crea las tres versiones del contenido (blog, LinkedIn, X) si no existen ya.
  2. Inserta tres filas en una tabla publish_jobs con estado pending, una por plataforma.
  3. Devuelve 200 OK al autor inmediatamente.

El usuario no espera. El procesado vive en un worker de Cloudflare que poll-ea la tabla cada cinco segundos, coge los pending, y para cada uno invoca el adapter correspondiente. Cada adapter es un módulo que sabe hablar con UNA plataforma — linkedin.ts, x.ts, blog.ts — y devuelve { ok: boolean, externalId?, error? }.

La elección de "cola en BD" en vez de un broker dedicado (Cloudflare Queues, SQS) es deliberada: para el volumen actual la BD basta y simplifica el debugging. Si una publicación se atasca, el autor ve el estado en la UI sin necesidad de un dashboard separado. Y los retries son un UPDATE con incremento de contador.

LinkedIn: el bug del authorURN

La API de LinkedIn es funcional pero tiene un par de aristas que cuestan tiempo descubrir.

La primera, y la más memorable: el campo author en una request a /v2/ugcPosts no es tu user ID. Es un URN con formato exacto:

urn:li:person:<LINKEDIN_USER_ID>

Si lo mandas como "author": "<USER_ID>" a secas, recibes un 400 con un mensaje que parece sobre permisos. No lo es. Es un parsing error mal disfrazado. Te tiras dos horas revisando OAuth scopes antes de leer la doc por sexta vez y verlo.

La segunda: el formato del payload depende del tipo de post. Texto plano usa specificContent.com.linkedin.ugc.ShareContent.shareCommentary.text. Si añades un enlace al blog (lo que hacemos siempre), la URL va en un campo distinto y necesitas shareMediaCategory: "ARTICLE" con metadata adicional. Diferente esquema, dos rutas distintas en el código del adapter.

El adapter completo cabe en 80 líneas. Las primeras cinco veces que falló fue siempre por una de estas dos cosas. Hoy las captura un test E2E con un mock de LinkedIn que reproduce ambos errores como casos esperados.

X: rate limits y la magia del thread

X (Twitter) tiene una API más limpia pero rate limits agresivos. El plan gratuito da 1.500 tweets / mes / usuario. Suficiente para un autor activo, ridículo si quisieras automatizar threads de un equipo.

Para hilos, hay que encadenar manualmente. Cada tweet es un POST a /2/tweets con un reply.in_reply_to_tweet_id apuntando al anterior. Si el segundo POST falla a mitad — pasa una vez de cada cien — te queda un hilo huérfano de un solo tweet. La política del adapter es:

  1. POSTear el primer tweet, guardar el ID.
  2. POSTear cada siguiente con in_reply_to_tweet_id al anterior.
  3. Si cualquiera falla, abortar y marcar el job como partial_failure.
  4. NUNCA borrar los tweets ya publicados — el autor decide si quiere completar a mano o dejar el primer tweet vivo.

El borrado automático parece elegante hasta que un tweet con tracción se borra solo porque la red parpadeó en el tweet 3 de 4. La regla es: publicación es irreversible desde el punto de vista del adapter. Lo que se publicó, se publicó.

El blog: el adapter más simple, el que más tarda

Sorpresa contra-intuitiva: el adapter del blog (que hace un INSERT en la BD propia) es el más simple en código pero el más lento en latencia. Razón: además del INSERT, se ejecutan dos cosas que tardan:

  1. Generación del JSON-LD de schema.org con el grafo completo (autor, tags, fechas, imagen).
  2. Notificación a IndexNow a Bing, Yandex, Seznam, Naver y Yep para re-crawl. Esto se hace en paralelo, pero la suma es ~600ms.

Esto se ejecuta de forma asíncrona DESPUÉS de devolver el 200 al usuario, pero antes de marcar el job como complete. Si IndexNow falla en uno de los cinco motores, no marcamos fallo — el coste de no notificar a Yandex es bajo, y los retries fragmentarían la lógica.

Failure: 2 de 3 sigue siendo éxito

La pregunta política del pipeline: si el blog confirma, LinkedIn confirma, y X falla — ¿el job es éxito o fallo?

La respuesta de Agentikas: éxito parcial. El estado partial_failure existe a propósito. La UI muestra "publicado en blog y LinkedIn, X falló — reintentar?". El autor decide. La razón es práctica: la mayoría de fallos en X son rate limits temporales o errores 500 transitorios. Reintentar manualmente en cinco minutos arregla el 90% de casos. Reintentar automáticamente desde el adapter mete posts duplicados a las plataformas que sí funcionaron.

Esto requiere idempotencia: si un job vuelve a procesarse, el adapter del blog comprueba si ya hay un post con ese publish_job_id en la BD antes de insertar. El de LinkedIn comprueba el externalId. El de X, igual. Ningún POST duplicado bajo ninguna circunstancia.

El coste real, en números

Para cerrar con la pregunta que importa cuando todo está open source: ¿cuánto cuesta esto?

  • Generación con Claude Haiku 4.5: ~3.5 céntimos por post (incluye blog + LinkedIn + X versions, brand review, traducción).
  • Llamadas a la API de LinkedIn: gratis (plan free).
  • Llamadas a la API de X: gratis si estás bajo 1.500/mes.
  • Cloudflare Workers: el worker de la cola consume <1ms de CPU por job. Cabe holgadamente en el plan free.
  • Postgres / Supabase: la tabla publish_jobs tiene 4 columnas y se purga a los 30 días. Coste despreciable.

Total: menos de cinco céntimos por publicación completa a las tres plataformas. El precio público al autor: cero. Lo absorbe Agentikas Labs porque la plataforma es sin ánimo de lucro.


El adapter de LinkedIn, el de X, el worker de la cola y la migración de publish_jobs son ficheros separados en github.com/agentikas/agentikas-blog. Si alguno de los gotchas que mencionamos te suena raro, abre el código — el comentario que avisa del bug del authorURN sigue ahí.

Comentarios

Cargando comentarios…