Saltar al contenido
Development

Agregando Dev.to como destino de sindicación

Por Victor Da Luz
railsrubydevtodev-logblog-manager

Ya tengo una app en Rails que importa publicaciones a Medium y dispara un anuncio en LinkedIn una vez que se publican. Agregar Dev.to se sentía como el siguiente paso obvio, ya que ahí está buena parte de la audiencia de desarrolladores. Pensé que sería rápido porque la arquitectura ya estaba ahí.

En su mayoría lo fue. Pero hubo algunas cosas que me sorprendieron.

La API simplemente es mejor

La API de Medium está obsoleta. Tengo que usar un token que expira, sondear un feed RSS para detectar cuándo se publica un borrador, e inyectar etiquetas <figure> para las imágenes principales porque la API no tiene un campo main_image. Todo el asunto se sostiene con manipulación de strings.

Dev.to es lo opuesto. La API de Forem está documentada, mantenida activamente, y hace lo que uno esperaría: POST /api/articles crea un borrador, PUT /api/articles/{id} lo actualiza, GET /api/articles/me/all devuelve tu lista completa de artículos con marcas de tiempo published_at. Sin RSS. Sin scraping. La imagen principal es solo un campo en el payload.

La autenticación también es más simple: un solo header api-key desde la configuración de tu cuenta, guardado como una columna encriptada en el modelo Blog.

Reflejando la arquitectura de Medium

Mi integración de Medium usa un patrón donde cada servicio que hace llamadas HTTP recibe un Proc connection: que devuelve una respuesta falsa en las pruebas. Esto me permite probar ramas de error (401, 429, 5xx) sin WebMock ni VCR; solo hay que pasar un lambda que devuelva un Struct.new(:code, :body).

Mantuve el mismo patrón para Devto::Client:

def initialize(token:, connection: default_connection)
  @token      = token
  @connection = connection
end

def create_article(payload:)
  response = request(Net::HTTP::Post, "/api/articles", payload)
  raise AuthError, "..." if response.code == "401"
  JSON.parse(response.body)
end

El resto de la capa de servicios refleja a Medium casi exactamente: DraftCreator, Publisher, PublishedSync. La principal diferencia es que PublishedSync reemplaza tanto a RssPoller como a PublishedBackfill de Medium, porque la API devuelve todos los artículos con su published_at, así que no hay nada que rellenar por separado.

La refactorización del anuncio de LinkedIn

Una vez que tuve dos plataformas de sindicación, la lógica de anuncio en LinkedIn necesitaba vivir en un lugar compartido. Antes estaba en línea dentro de MediumSyncJob, un método privado que revisaba expiración de tokens, conteo de reintentos, e idempotencia.

La extraje a Syndication::LinkedInAnnounce, un PORO pequeño que toma un blog y recorre las publicaciones elegibles:

def eligible_posts
  @blog.posts
       .where(medium_status: Post.medium_statuses[:published])
       .or(@blog.posts.where(devto_status: Post.devto_statuses[:published]))
       .select { |p| p.linkedin_not_posted? || (p.linkedin_failed? && p.linkedin_attempts < 3) }
end

La consulta .or() significa que una publicación publicada en cualquiera de las dos plataformas dispara LinkedIn. El select maneja la idempotencia: si la publicación ya tiene linkedin_status: :posted, se salta. Una publicación publicada tanto en Medium como en Dev.to solo se anuncia una vez.

Tanto MediumSyncJob como DevtoSyncJob ahora llaman a announcer_class.new(blog).call, donde announcer_class es un class_attribute que por defecto es Syndication::LinkedInAnnounce, inyectable en las pruebas sin ninguna magia de stubbing.

El problema de Zeitwerk

Nombré la clase Syndication::LinkedinAnnounce (con “in” en minúscula). Parecía natural. Las pruebas explotaron de inmediato con NameError: uninitialized constant Syndication::LinkedinAnnounce.

El problema: config/initializers/inflections.rb tiene inflect.acronym "LinkedIn". Zeitwerk usa el inflector de Rails para mapear nombres de archivo a nombres de constantes, así que linkedin_announce.rb se mapea a LinkedInAnnounce, no a LinkedinAnnounce. El nombre de la clase y la constante esperada divergieron en silencio.

El arreglo fue renombrar la clase a Syndication::LinkedInAnnounce. Vale la pena saberlo: cualquier archivo de servicio cuyo nombre contenga un acrónimo registrado (OAuth, GitHub, AWS) tiene que usar la forma inflectada o Zeitwerk no lo va a encontrar.

Pruebas

Las mismas convenciones de Minitest que el resto de la app: sin WebMock, sin VCR. Las pruebas del job usan inyección por class_attribute:

setup do
  @original_sync      = DevtoSyncJob.sync_class
  @original_announcer = DevtoSyncJob.announcer_class
end

teardown do
  DevtoSyncJob.sync_class      = @original_sync
  DevtoSyncJob.announcer_class = @original_announcer
end

Guardar el original, inyectar uno falso, restaurar en el teardown. Funciona bien con ejecución de pruebas en paralelo porque cada proceso de prueba tiene su propia instancia de clase.

La prueba de idempotencia es explícita; una publicación publicada en ambas plataformas debería aparecer solo una vez en el registro de llamadas del anunciador:

test "idempotent: a post published on both Medium and Dev.to is only announced once" do
  post = @blog.posts.create!(slug: "p", medium_status: :published, devto_status: :published, ...)
  calls = []
  Syndication::LinkedInAnnounce.new(@blog, creator_class: creator_class(calls)).call
  assert_equal 1, calls.count { |p| p.id == post.id }
end

Qué haría diferente

La sanitización de etiquetas (gsub(/[^a-z0-9]/i, "").downcase) es tosca. “C++” se convierte en “c”, “C#” se convierte en “c”. Dev.to tiene restricciones de etiquetas (minúsculas, alfanuméricas, máximo 4), pero debería manejar mejor los casos comunes, o al menos registrar cuando una etiqueta queda mutilada para que sea visible. Ahora mismo falla en silencio.

También me salté el descubrimiento de nombre de usuario. La API tiene GET /users/me que me permitiría guardar devto_username en el modelo Blog. Lo dejé para después ya que la URL del artículo de todas formas viene en la respuesta de creación.

Siguiente

Hashnode está en la lista. Mismo patrón, forma de API distinta. A estas alturas la arquitectura de sindicación está lo bastante estable como para que agregar una tercera plataforma sea sobre todo aditivo.

Lecturas relacionadas

Development

Borrando una integración al moverla a Postiz

Enrutando la publicación en Dev.to a través de Postiz y borrando el cliente nativo: el DTO que valida más estricto que el runtime, el borrador que no lo es, y una imagen de portada perdida en almacenamiento local.

Leer