Pular para o conteúdo
Development

Quando o Medium não tem API: polling de RSS e cross-posting pro LinkedIn

Por Victor Da Luz
railsrubymediumlinkedindev-logblog-manager

Venho construindo o blog-manager pra automatizar as partes chatas de publicar: faço push pro GitHub, e o post aparece no Medium e no LinkedIn sem eu precisar mexer em três dashboards diferentes. Essa semana conectei as duas últimas peças: detectar quando um rascunho do Medium realmente vai ao ar, e fazer cross-post automático pro LinkedIn quando isso acontece.

O problema do Medium

Quando mapeei isso pela primeira vez num spike, descobri que a API do Medium não tem um endpoint GET /posts/{id}. Não tem como consultar o status de um post pelo ID. A única forma de saber se um rascunho foi ao ar é checar o feed RSS do usuário, https://medium.com/feed/@username, que só contém posts publicados. Rascunhos nunca aparecem.

Então o fluxo de detecção é: guardar a URL do Medium quando um rascunho é criado (já resolvido no trabalho de importação), depois, diariamente, buscar o feed RSS e checar se alguma URL de rascunho salva aparece nele. Se aparecer, o post está publicado.

Isso é meio deselegante; estou basicamente fazendo polling numa URL pública feita pra leitores de feed. Mas funciona, e o Medium não promete alternativa nenhuma. O feed tem um teto de 10 posts, então eu comparo por URL em vez de posição.

Construindo o Medium::RssPoller

A stdlib do Ruby inclui uma gem rss pra fazer parse de RSS, mas ela precisa estar listada explicitamente no Gemfile sob o bundler. Aprendi isso do jeito difícil quando funcionou isolado mas quebrou sob bundle exec. Correção de uma linha: gem "rss" no Gemfile.

O poller é direto: busca o feed, faz parse, retorna um Set de URLs normalizadas (minúsculas, barras finais removidas, query strings descartadas). A normalização importa porque a URL guardada no banco pode voltar da API do Medium ligeiramente diferente do que aparece na tag <link> do RSS.

Pra testar sem bater na rede, eu injeto a conexão HTTP como um proc:

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

Passo um proc nos testes que retorna um objeto HTTP falso, e o poller não percebe diferença. É o mesmo padrão que usei pro Medium::Client.

O lado do LinkedIn

O LinkedIn vem migrando do endpoint antigo /v2/ugcPosts pro /rest/posts. O endpoint novo exige quatro headers, e a falta de qualquer um deles retorna um erro críptico:

  • Authorization: Bearer {token}
  • Content-Type: application/json
  • X-Restli-Protocol-Version: 2.0.0
  • LinkedIn-Version: 202504, uma string de versão mensal rotativa que o LinkedIn muda periodicamente

Esse último header é o que pega as pessoas de surpresa. É obrigatório, e o LinkedIn descontinua versões antigas silenciosamente numa janela rotativa de cerca de 12 meses. Defini ele como uma constante com um comentário pra atualizar anualmente:

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

A resposta de sucesso é um 201 com a URN do post no header de resposta x-restli-id, não no corpo. Fácil de passar batido se você está olhando no lugar errado.

O job

O MediumSyncJob roda diariamente às 15:00 UTC (09:00 Costa Rica) via o recurso de job recorrente do Solid Queue. Pra cada blog com token do Medium:

  1. Extrai o username do Medium de qualquer URL de post existente (regex em medium.com/@username/)
  2. Faz polling do feed RSS pras URLs publicadas
  3. Pra cada post em rascunho/agendado cuja URL aparece no feed, marca como publicado
  4. Pra cada post recém-publicado (e qualquer post falho no LinkedIn com menos de 3 tentativas), chama o LinkedIn::PostCreator

O job envolve cada blog num rescue pra que um blog com problema não derrube a execução inteira. Erros de postagem no LinkedIn são capturados por post pelo mesmo motivo.

A pegadinha do Zeitwerk / migration

O Rails usa o inflector do ActiveSupport tanto pra autoload de arquivos quanto pra constantizar nomes de classe de migration. Adicionei inflect.acronym "LinkedIn" pra que o Zeitwerk carregasse app/services/linkedin/client.rb como LinkedIn::Client em vez de Linkedin::Client.

Funcionou ótimo pro autoload. E aí quebrou o rollback de migration no CI. Quando o Rails reverte uma migration, ele camelizes o nome do arquivo pra achar a classe. Com a regra de acrônimo em vigor, add_linkedin_attempts_to_post vira AddLinkedInAttemptsToPost (I maiúsculo). Mas o gerador de migration tinha escrito AddLinkedinAttemptsToPost (i minúsculo). O CI pegou isso no check de rollback antes de eu perceber localmente.

A correção foi simples, renomear a classe no arquivo de migration, mas é o tipo de coisa que só aparece quando você roda db:rollback, o que eu não estava fazendo localmente.

Testando sem stub

Os testes do job foram a parte mais complicada. Eu queria injetar pollers e creators falsos pra que os testes não batessem na rede. Meu primeiro instinto foi Medium::RssPoller.stub(:new, fake_poller), mas o Minitest 6 removeu o método stub do minitest/mock. Exigir isso levanta LoadError.

A solução: usar class_attribute do ActiveSupport pra tornar as classes de serviço injetáveis no nível de classe:

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

Nos testes, troco o class attribute por uma classe falsa e reseto no 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 com um bloco cria um closure, então a classe falsa consegue capturar variáveis locais do método de teste. Isso é útil pro teste de “recuperação de erro”, em que o poller deveria falhar na primeira chamada mas ter sucesso na segunda.

Também tem uma pegadinha sutil do Ruby com define_singleton_method: dentro do corpo do método, self vira o objeto alvo, não a instância do teste. Então métodos auxiliares da classe de teste (como make_response) não ficam acessíveis. Solução: capturar o valor de retorno numa variável local antes de entrar no define_singleton_method, e fechar sobre ela.

O que vem a seguir

O fluxo de reautenticação OAuth do LinkedIn ainda está faltando. O token expira a cada 60 dias, e agora não tem como renovar isso pelo app. O chip de aviso de expiração de token na página de índice do blog já está lá, mas clicar nele não faz nada útil. Esse é o próximo issue.

Leitura relacionada

Você também pode achar útil

Proton

Proton VPN

VPN comercial com filtragem NetShield e interruptor de desligamento automático.

Como parceiro da Proton, ganho com compras qualificadas dos serviços de privacidade e segurança da Proton (Pass, Mail, VPN, Drive).

Saiba mais
RackNerd

RackNerd VPS

Hospedagem VPS econômica para serviços leves que funcionam continuamente.

Como afiliado da RackNerd, ganho com compras qualificadas.

Saiba mais
Airalo

eSIM Airalo

eSIM de dados local para viagens - sem necessidade de trocar um SIM físico.

Este é meu link de indicação da Airalo. Você recebe um desconto no seu primeiro eSIM e eu ganho crédito da Airalo para o meu.

Saiba mais