Saltar al contenido
Development

Construyendo la publicación cruzada a Medium para blog-manager

Por Victor Da Luz
railsrubymediumdev-logblog-manager

Hace tiempo que publico entradas en mi blog en vdaluz.com, y el paso de publicación cruzada a Medium siempre fue manual: copiar el HTML, pegarlo, poner la URL canónica, recortar el título si era muy largo, elegir etiquetas, hacer clic en borrador. Ya había escrito una app en Rails para administrar el blog, así que agregar un botón de “publicar en Medium” parecía el siguiente paso natural.

Así lo construí.

Qué estaba tratando de lograr

El objetivo era simple en el papel: hacer clic en un botón en la página de detalle de una entrada, que la app mande la entrada a Medium como borrador con la URL canónica apuntando de vuelta a vdaluz.com, y que me muestre feedback en vivo mientras pasa.

Las restricciones vinieron de la propia API de Medium, que está oficialmente obsoleta desde 2023 pero todavía funciona bien con tokens de integración autoemitidos:

  • POST /v1/users/{authorId}/posts con publishStatus: "draft" y contentFormat: "html"
  • Título, máximo 100 caracteres
  • Etiquetas: hasta 3, cada una con máximo 25 caracteres
  • No hay un campo dedicado para la imagen de cabecera, se antepone una etiqueta <img> al cuerpo del HTML y Medium la detecta

Qué construí

Tres piezas principales:

Medium::Client, un envoltorio delgado sobre Net::HTTP. Nada elaborado. Recibe un token, hace un POST, devuelve la respuesta parseada o lanza un error tipado (AuthError, RateLimitError, Error). La parte interesante es un parámetro inyectable connection: que reemplaza a Net::HTTP.start en las pruebas. Sin WebMock, sin hacer stub de métodos de clase de forma global, solo se pasa un lambda que devuelve un struct de respuesta falso.

def initialize(token:, connection: nil)
  raise AuthError, "medium token is blank" if token.blank?
  @token = token
  @connection = connection
end

connect = @connection || Net::HTTP.method(:start)
res = connect.call(uri.host, uri.port, use_ssl: true, ...) { |http| http.request(req) }

Medium::DraftCreator, el orquestador. Trae el markdown de la entrada desde GitHub (la fuente de verdad), quita el frontmatter, renderiza el MD a HTML vía commonmarker, arma el payload, llama al cliente, y actualiza el registro de la entrada si tiene éxito.

El filtrado de etiquetas terminó siendo dos reglas en una sola pasada:

def filtered_tags
  Array(@post.tags).select { |t| t.to_s.length <= TAG_MAX_LENGTH }.first(TAG_MAX_COUNT)
end

Se descarta todo lo que supere los 25 caracteres, y después se toman las primeras 3. El orden importa: si se invirtiera, se quedarían 3 etiquetas y después potencialmente se descartarían algunas que eran válidas.

MediumImportJob, un job asíncrono de Solid Queue que refleja al job de scan. La decisión de diseño clave acá fue la propiedad: el job es dueño de medium_import_state (idle/running/failed) y el servicio es dueño de medium_status (not_imported/draft/published). Son enums separados en el mismo modelo. El job pone running antes de llamar al servicio, y después idle si tiene éxito o failed si hay un error. El servicio nunca toca el estado de importación.

def perform(post)
  post.update!(medium_import_state: :running, medium_error: nil)
  Medium::DraftCreator.new(post).call
  post.update!(medium_import_state: :idle)
  broadcast_post(post)
rescue Medium::Client::AuthError
  raise  # discard_on handles this at the class level
rescue StandardError => e
  post.update!(medium_import_state: :failed, medium_error: "#{e.class.name.demodulize}: #{e.message}")
  broadcast_post(post)
  raise
end

Cada cambio de estado dispara un broadcast de Turbo Stream que reemplaza el partial de la entrada en el navegador. La página de detalle se suscribe con turbo_stream_from @post y el partial maneja los cuatro estados: idle (botón de importar), running (spinner), draft (enlace para ver), failed (mensaje de error con reintento).

Decisiones que tomé y por qué

Sin bloqueo de Pexels. La especificación original decía que el botón de importar solo debería estar activo una vez seleccionada una imagen de cabecera de Pexels (esa es la funcionalidad de selección de imagen, todavía no construida). Publiqué sin ese bloqueo; el botón está activo para cualquier entrada not_imported con un token de blog configurado. Cuando llegue la funcionalidad de Pexels, la condición se vuelve más estricta y al payload se le antepone un <img>. Publicar ahora significó poder probar la integración real de Medium sin esperar a una funcionalidad no relacionada.

Traer el HTML bajo demanda. Podría haber guardado el HTML renderizado en la base de datos. En cambio, DraftCreator trae el markdown desde GitHub y lo renderiza en el momento de la importación. No hace falta ningún cambio de esquema, siempre refleja el contenido actual del archivo, y la consulta a GitHub es una operación de una sola vez por importación, no un hot path.

base_url en los blogs. La URL canónica necesita ser https://vdaluz.com/blog/slug. Agregué una columna base_url a la tabla de blogs y la expuse en el formulario de edición del blog. La validación es una regex sobre URI.regexp(%w[http https]) con allow_blank: true; necesaria para que funcionen las URLs canónicas, pero sin bloquear a los blogs que todavía no la configuraron.

Dependencias inyectables en todos lados. Medium::Client recibe connection:, Medium::DraftCreator recibe client: y github_client:. Las pruebas pasan objetos falsos directamente sin tocar estado global. Esto hizo que probar fuera simple y rápido; toda la suite de pruebas corre en menos de un segundo para estos archivos nuevos.

Lo que me sorprendió

El problema del toggle public. En un momento tenía def index definido debajo de private en el controller, con una reapertura public para volver a sacarlo. Funcionaba, Ruby no tiene problema con esto, pero era incómodo de leer. Moví index arriba de private, donde corresponde.

URI::regexp versus URI.regexp. RuboCop marcó una infracción de estilo en mi validación inicial: URI::regexp usa :: para una llamada a método, cuando debería ser URI.regexp. Ambas formas funcionan en Ruby, pero la forma con :: técnicamente está llamando a un método, no accediendo a una constante, así que RuboCop la marca bajo Style/ColonMethodCall. Arreglo fácil, pero bloqueaba el commit.

La propiedad de la máquina de estados llevó un par de iteraciones. Mi primera versión tenía a DraftCreator poniendo medium_import_state: :idle después del éxito. Eso rompía la prueba del job; el creator falso no fijaba ese estado, así que la prueba veía running en vez de idle. Mover el reset a idle hacia el job lo arregló, y de hecho era el diseño correcto: el job inició la transición de estado, así que el job debería terminarla.

Qué sigue

La funcionalidad de selección de imagen de Pexels va a volver más estricta la condición del botón de importar y a anteponer la imagen de cabecera al payload HTML. Esa es una adición limpia; DraftCreator va a recibir un parámetro opcional image_url: y va a anteponer <img src="..."> a body_html cuando esté presente.

El riesgo de que la API de Medium se apague es real. El punto de integración es lo bastante acotado (Medium::Client tiene 40 líneas) como para que cambiarlo por otra implementación, o desactivar la funcionalidad por completo, toque un solo archivo.

Lecturas relacionadas