Automatizar la publicacion en plataformas externas suena facil hasta que chocas con los problemas reales: limites de API, duplicados, reintentos, estado inconsistente y pipelines que se rompen por errores ajenos.
En este post te cuento, paso a paso y con codigo, como armamos un sistema de sincronizacion para publicar articulos del blog en Dev.to y Medium sin exponer secretos y sin romper el flujo normal de deploy.
Objetivo real del sistema
Queríamos resolver cuatro cosas:
- Publicar solo articulos pendientes (no repetir).
- Evitar rate limits de Dev.to (
429). - Separar deploy de producto y crosspost (si falla uno, no cae todo).
- Mantener arquitectura extensible (agregar plataformas sin reescribir todo).
Arquitectura elegida (simple y mantenible)
Usamos un enfoque de puertos/adaptadores:
- Domain: entidad
BlogPost. - Application: caso de uso
CrossPostArticlesUseCase. - Infrastructure:
- repositorio de posts (
AstroMarkdownPostRepository), - repositorio de estado (
FilePublicationStateRepository), - publishers por plataforma (
DevToPublisher,MediumPublisher).
- repositorio de posts (
Esto evita acoplar lógica de negocio a detalles HTTP de cada API.
Paso 1: modelar el post como entidad
export class BlogPost {
constructor({ slug, title, description, tags, pubDate, canonicalUrl, body, lang }) {
this.slug = slug;
this.title = title;
this.description = description;
this.tags = tags;
this.pubDate = pubDate;
this.canonicalUrl = canonicalUrl;
this.body = body;
this.lang = lang;
}
}
Detalle importante: incluimos lang para poder filtrar publicaciones por idioma (en o es).
Paso 2: leer markdown y construir canonical correcto
El repositorio parsea frontmatter, ignora drafts y construye canonical por idioma:
const canonicalUrl = `${this.siteUrl}/${lang}/blog/${slug}/`;
Con eso evitamos publicar en plataformas externas una canonical vieja como /blog/<slug>/ cuando el sitio ya está en /{lang}/blog/<slug>/.
Paso 3: controlar duplicados con estado persistente
Guardamos estado por plataforma:
.crosspost/state-devto.json.crosspost/state-medium.json
Ejemplo:
{
"posts": {
"49-como-construimos-un-sistema-de-sincronizacion-automatizada-devto-medium": {
"platforms": {
"devto": {
"id": "123456",
"url": "https://dev.to/...",
"canonicalUrl": "https://francgs.dev/es/blog/49-como-construimos.../",
"syncedAt": "2026-05-05T10:00:00.000Z"
}
}
}
}
}
Si un post ya está sincronizado, se hace skip.
Paso 4: manejar rate limit de Dev.to
El error fuerte que vimos fue 429 Retry later cuando intentábamos publicar demasiados posts en una corrida.
Solución aplicada:
- delay mínimo entre publicaciones (
minIntervalMs), - retries con backoff leyendo
Retry-Aftero mensaje del body, - límite de posts por corrida (
CROSSPOST_MAX_POSTS=1).
Snippet clave:
if (response.status === 429) {
const retrySeconds = parseRetrySeconds(response, errorBody);
await sleep(retrySeconds * 1000);
attempt += 1;
continue;
}
Paso 4.1: corregir error 422 por tags invalidos en Dev.to
En migracion real aparecio otro fallo: 422 por tags con guiones (ej: web-development).
Dev.to en API valida tags como alfanumericos, asi que reforzamos sanitizacion:
const sanitizeTags = (tags) =>
[...new Set(
tags
.map((tag) => tag.normalize("NFKD").replace(/[\u0300-\u036f]/g, ""))
.map((tag) => tag.toLowerCase().replace(/[^a-z0-9]/g, ""))
.map((tag) => tag.slice(0, 20))
.filter(Boolean)
)].slice(0, 4);
Con esto:
web-development->webdevelopmentengineering-culture->engineeringculture
y evitamos que una corrida completa falle por formato de tags.
Paso 5: ejecutar en modo migracion por cola
Creamos workflow dedicado con cron cada 5 minutos:
on:
schedule:
- cron: "*/5 * * * *"
Y publicamos 1 artículo por ejecución:
env:
CROSSPOST_LANGS: en
CROSSPOST_MAX_POSTS: "1"
CROSSPOST_DEVTO_MAX_RETRIES: "6"
CROSSPOST_DEVTO_MIN_INTERVAL_MS: "5000"
Esto convierte la migración en una cola estable en lugar de un “todo junto” que rompe por rate limit.
Paso 6: no romper deploy principal
El crosspost es importante, pero no debe tumbar producción.
Por eso usamos:
continue-on-error: true
y además agregamos CROSSPOST_FAIL_ON_ERROR=false.
Resultado: si falla una plataforma, el deploy del sitio igual termina bien.
Paso 7: filtrar idioma y plataforma por entorno
En el entrypoint de crosspost soportamos configuración por variables:
CROSSPOST_LANGS=enCROSSPOST_MAX_POSTS=1CROSSPOST_STATE_PATH=.crosspost/state-devto.json--platform=devtoo--platform=medium
Esto nos permite correr estrategias distintas según etapa.
Flujo operativo recomendado
- Migración inicial: cron cada 5 minutos, 1 post por run, solo inglés.
- Transición: cuando backlog baja, mantener sync en deploy con límite bajo.
- Mantenimiento: publicar solo faltantes y monitorear 429.
Checklist de implementación (sin secretos)
- Definir entidad de contenido (
BlogPost) - Implementar repositorio markdown + canonical por idioma
- Guardar estado de sincronización por plataforma
- Implementar deduplicación por estado + canonical
- Implementar retries/backoff para 429
- Limitar cantidad por run (
MAX_POSTS) - Ejecutar en cron de migración
- Separar crosspost del deploy principal
Errores que ya evitamos con este diseño
- Publicación masiva que dispara rate limits.
- Duplicados por no tener estado persistente.
- Canonicals mal construidas al migrar a rutas por idioma.
- Deploy caído por error de una API externa.
Cierre
La clave no fue “hacer más script”, fue diseñar un flujo operativo: idempotente, lento cuando conviene, y desacoplado del deploy.
Si querés replicarlo, empezá simple: 1 post por corrida, estado persistente, retries explícitos y métricas de avance. Con eso ya estás en producción seria.
Relacionado:
Happy reading! ☕
Comments