Cuando Medium no tiene API: sondeo de RSS y republicación cruzada en LinkedIn
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/jsonX-Restli-Protocol-Version: 2.0.0LinkedIn-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:
- Extraer el username de Medium de cualquier URL de post existente (regex sobre
medium.com/@username/) - Sondear el feed RSS en busca de URLs publicadas
- Para cada borrador o post programado cuya URL aparezca en el feed, marcarlo como publicado
- 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
Cómo construí un backfill de sindicación del blog
72 publicaciones de Medium de las que la base de datos no sabía nada: el límite de RSS, un workaround no documentado de GraphQL, apóstrofes Unicode, y la trampa del stdin de kamal.
Construyendo la publicación cruzada a Medium para blog-manager
Un botón para publicar en Medium sobre una API obsoleta pero funcional: Net::HTTP inyectable, renderizado de markdown bajo demanda, y una máquina de estados dividida entre el job y el servicio.
Rastrear publicaciones programadas de Medium sin una extensión de navegador
La API de Medium no tiene el concepto de una publicación programada. Fase 1: modelar el estado manualmente con un enum de solo agregar, y ver si la fricción justifica la automatización.