Saltar al contenido
Development

Cuando Medium no tiene API: sondeo de RSS y republicación cruzada en LinkedIn

Por Victor Da Luz
railsrubymediumlinkedindev-logblog-manager

He estado construyendo blog-manager para automatizar las partes aburridas de publicar: hago push a GitHub y el contenido aparece en Medium y LinkedIn sin que tenga que tocar tres paneles distintos. Esta semana conecté las últimas dos piezas: detectar cuándo un borrador de Medium realmente se publica, y republicarlo automáticamente en LinkedIn cuando eso ocurre.

El problema de Medium

Cuando exploré esto por primera vez en un spike, descubrí que la API de Medium no tiene un endpoint GET /posts/{id}. No hay forma de consultar el estado de un post por ID. La única manera de saber si un borrador se publicó es revisar el feed RSS del usuario, https://medium.com/feed/@username, que solo contiene posts publicados. Los borradores nunca aparecen.

Entonces el flujo de detección es: guardar la URL de Medium cuando se crea un borrador (ya resuelto en el trabajo de importación), y luego, a diario, traer el feed RSS y revisar si aparece alguna de las URLs de borradores guardadas. Si aparece, el post está publicado.

Es un poco inelegante: básicamente estoy sondeando una URL pública pensada para lectores de feeds. Pero funciona, y Medium no promete ninguna alternativa. El feed está limitado a 10 posts, así que comparo por URL en vez de por posición.

Construyendo Medium::RssPoller

La stdlib de Ruby incluye la gema rss para parsear RSS, pero hay que declararla explícitamente en el Gemfile bajo bundler. Lo aprendí de la forma difícil, cuando funcionaba aislado pero explotaba bajo bundle exec. Arreglo de una línea: gem "rss" en el Gemfile.

El poller es simple: trae el feed, lo parsea, y devuelve un Set de URLs normalizadas (en minúsculas, sin barra final, sin query string). La normalización importa porque la URL guardada en la base de datos puede venir de la API de Medium ligeramente distinta de lo que aparece en la etiqueta <link> del RSS.

Para probar sin tocar la red, inyecto la conexión HTTP como un proc:

def initialize(username, connection: nil)
  @username = username
  @connection = connection || Net::HTTP.method(:start)
end

En las pruebas, paso un proc que devuelve un objeto HTTP falso, y el poller no nota la diferencia. Es el mismo patrón que usé para Medium::Client.

El lado de LinkedIn

LinkedIn ha estado alejándose del viejo endpoint /v2/ugcPosts hacia /rest/posts. El endpoint nuevo requiere cuatro encabezados, y si falta alguno devuelve un error críptico:

  • Authorization: Bearer {token}
  • Content-Type: application/json
  • X-Restli-Protocol-Version: 2.0.0
  • LinkedIn-Version: 202504, un string de versión mensual rotativo que LinkedIn cambia periódicamente

Ese último encabezado es el que atrapa a la gente. Es obligatorio, y LinkedIn da de baja versiones antiguas en silencio, con un calendario rotativo de aproximadamente 12 meses. Lo definí como una constante con un comentario para actualizarla cada año:

LINKEDIN_VERSION = "202504" # bump periodically per LinkedIn's rolling version policy

La respuesta exitosa es un 201 con el URN del post en el encabezado de respuesta x-restli-id, no en el body. Fácil de pasar por alto si se está mirando en el lugar equivocado.

El job

MediumSyncJob corre a diario a las 15:00 UTC (09:00 Costa Rica) mediante la función de jobs recurrentes de Solid Queue. Para cada blog con un token de Medium:

  1. Extraer el username de Medium de cualquier URL de post existente (regex sobre medium.com/@username/)
  2. Sondear el feed RSS en busca de URLs publicadas
  3. Para cada borrador o post programado cuya URL aparezca en el feed, marcarlo como publicado
  4. Para cada post recién publicado (y cualquier post de LinkedIn fallido con menos de 3 intentos), llamar a LinkedIn::PostCreator

El job envuelve cada blog en un rescue para que un blog problemático no tumbe toda la corrida. Los errores al publicar en LinkedIn se capturan por post, por la misma razón.

La trampa de Zeitwerk y las migraciones

Rails usa el inflector de ActiveSupport tanto para autocargar archivos como para convertir en constante los nombres de clase de las migraciones. Agregué inflect.acronym "LinkedIn" para que Zeitwerk cargara app/services/linkedin/client.rb como LinkedIn::Client en vez de Linkedin::Client.

Eso funcionó perfecto para el autocargado. Y después rompió el rollback de una migración en CI. Cuando Rails hace rollback de una migración, convierte el nombre del archivo a camelCase para encontrar la clase. Con la regla de acrónimo activa, add_linkedin_attempts_to_post se convierte en AddLinkedInAttemptsToPost (I mayúscula). Pero el generador de migraciones había escrito AddLinkedinAttemptsToPost (i minúscula). CI lo detectó en el chequeo de rollback antes de que yo lo notara localmente.

El arreglo fue simple: renombrar la clase en el archivo de migración, pero es el tipo de cosa que solo aparece al correr db:rollback, algo que no estaba haciendo localmente.

Pruebas sin stub

Las pruebas del job fueron la parte más complicada. Quería inyectar pollers y creators falsos para que las pruebas no tocaran la red. Mi primer instinto fue Medium::RssPoller.stub(:new, fake_poller), pero Minitest 6 eliminó el método stub de minitest/mock. Requerirlo lanza LoadError.

La solución alternativa: usar class_attribute de ActiveSupport para que las clases de servicio sean inyectables a nivel de clase:

class MediumSyncJob < ApplicationJob
  class_attribute :poller_class, default: Medium::RssPoller
  class_attribute :creator_class, default: LinkedIn::PostCreator
  ...
end

En las pruebas, se cambia el class attribute por una clase falsa y se restaura en el teardown:

setup do
  @original_poller = MediumSyncJob.poller_class
  MediumSyncJob.poller_class = Class.new do
    define_method(:initialize) { |_username, **| }
    define_method(:call) { Set.new(["https://medium.com/@user/post-abc"]) }
  end
end

teardown { MediumSyncJob.poller_class = @original_poller }

define_method con un bloque crea un closure, así que la clase falsa puede capturar variables locales del método de prueba. Esto es útil para la prueba de “recuperación de error”, donde el poller debe lanzar una excepción en la primera llamada pero tener éxito en la segunda.

También hay una trampa sutil de Ruby con define_singleton_method: dentro del cuerpo del método, self pasa a ser el objeto de destino, no la instancia de prueba. Así que los métodos auxiliares de la clase de prueba (como make_response) no están accesibles. Solución: capturar el valor de retorno como variable local antes de entrar a define_singleton_method, y cerrar sobre esa variable.

Qué sigue

Todavía falta el flujo de reautenticación OAuth de LinkedIn. El token expira cada 60 días, y por ahora no hay forma de renovarlo desde la app. El chip de advertencia de expiración del token está presente en la página índice del blog, pero hacer clic en él no hace nada útil. Ese es el próximo issue.

Lecturas relacionadas