Quando o Medium não tem API: polling de RSS e cross-posting pro LinkedIn
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/jsonX-Restli-Protocol-Version: 2.0.0LinkedIn-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:
- Extrai o username do Medium de qualquer URL de post existente (regex em
medium.com/@username/) - Faz polling do feed RSS pras URLs publicadas
- Pra cada post em rascunho/agendado cuja URL aparece no feed, marca como publicado
- 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
Construindo um backfill de sindicação de blog
72 posts no Medium que meu banco de dados não conhecia: o limite do RSS, um workaround não documentado em GraphQL, apóstrofos Unicode, e a armadilha do stdin do kamal.
Construindo o cross-posting pro Medium no blog-manager
Um botão de publicar no Medium sobre uma API descontinuada mas funcional: Net::HTTP injetável, renderização de markdown sob demanda, e uma máquina de estados dividida entre job e service.
Rastreando posts agendados do Medium sem uma extensão de navegador
A API do Medium não tem o conceito de post agendado. Fase 1: modelar o estado manualmente com um enum append-only, e ver se a fricção justifica a automação.
Você também pode achar útil
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 maisRackNerd VPS
Hospedagem VPS econômica para serviços leves que funcionam continuamente.
Como afiliado da RackNerd, ganho com compras qualificadas.
Saiba maiseSIM 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