Construindo o cross-posting pro Medium no blog-manager
Venho publicando posts no meu blog em vdaluz.com há um tempo, e o passo de cross-posting pro Medium sempre foi manual: copiar o HTML, colar lá, definir a URL canônica, cortar o título se estivesse longo demais, escolher tags, clicar em rascunho. Eu já tinha escrito um app em Rails pra gerenciar meu blog, então adicionar um botão de “publicar no Medium” pareceu o próximo passo natural.
Foi assim que construí.
O que eu estava tentando fazer
O objetivo era simples no papel: clicar num botão na página de detalhe de um post, o app envia o post pro Medium como rascunho com a URL canônica apontando de volta pro vdaluz.com, e mostra feedback em tempo real enquanto isso acontece.
As restrições vieram da própria API do Medium, que está oficialmente descontinuada desde 2023 mas ainda funciona bem com tokens de integração autoemitidos:
POST /v1/users/{authorId}/postscompublishStatus: "draft"econtentFormat: "html"- Título com no máximo 100 caracteres
- Tags: até 3, cada uma com no máximo 25 caracteres
- Sem campo dedicado pra imagem de cabeçalho; você antepõe uma tag
<img>ao corpo HTML e o Medium a reconhece
O que eu construí
Três peças principais:
Medium::Client, um wrapper fino sobre Net::HTTP. Nada sofisticado. Recebe um token, faz um POST, devolve a resposta parseada ou lança um erro tipado (AuthError, RateLimitError, Error). O detalhe interessante é um parâmetro injetável connection: que substitui Net::HTTP.start nos testes. Sem WebMock, sem stub de métodos de classe globalmente, só passa uma lambda que devolve uma struct de resposta falsa.
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, o orquestrador. Ele busca o markdown do post no GitHub (a fonte da verdade), remove o frontmatter, renderiza o MD pra HTML via commonmarker, monta o payload, chama o client, e atualiza o registro do post quando dá certo.
Filtrar tags acabou virando duas regras numa única passada:
def filtered_tags
Array(@post.tags).select { |t| t.to_s.length <= TAG_MAX_LENGTH }.first(TAG_MAX_COUNT)
end
Descarta qualquer tag com mais de 25 caracteres, depois pega as 3 primeiras. A ordem importa; se você inverter, acaba mantendo 3 tags e potencialmente descartando outras que eram válidas.
MediumImportJob, um job assíncrono do Solid Queue que espelha o job de scan. A decisão de design chave aqui foi a propriedade: o job é dono de medium_import_state (idle/running/failed) e o service é dono de medium_status (not_imported/draft/published). São enums separados no mesmo model. O job define running antes de chamar o service, depois idle após sucesso ou failed após um erro. O service nunca toca no estado de import.
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 mudança de estado dispara um broadcast via Turbo Stream que substitui o partial do post no navegador. A página de exibição se inscreve com turbo_stream_from @post e o partial trata os quatro estados: idle (botão de import), running (spinner), draft (link pra ver), failed (mensagem de erro com retry).
Decisões que tomei e por quê
Sem gate do Pexels. A spec original dizia que o botão de import só deveria ficar ativo depois que uma imagem de cabeçalho do Pexels fosse selecionada (essa é a funcionalidade de seleção de imagem, ainda não construída). Lancei sem esse gate; o botão fica ativo pra qualquer post not_imported com um token de blog configurado. Quando a funcionalidade do Pexels chegar, a condição aperta e o payload ganha um <img> prependado. Lançar agora significou que eu podia testar a integração real com o Medium sem esperar por uma funcionalidade que não tinha nada a ver com isso.
Buscar o HTML sob demanda. Eu poderia ter guardado o HTML renderizado no banco. Em vez disso, o DraftCreator busca o markdown no GitHub e renderiza no momento do import. Sem mudança de schema, sempre reflete o conteúdo atual do arquivo, e a busca no GitHub é uma operação pontual por import, não um caminho quente.
base_url nos blogs. A URL canônica precisa ser https://vdaluz.com/blog/slug. Adicionei uma coluna base_url na tabela de blogs e a expus no formulário de edição de blog. A validação é uma regex sobre URI.regexp(%w[http https]) com allow_blank: true; necessária pra URLs canônicas funcionarem, mas não bloqueante pra blogs que ainda não configuraram isso.
Dependências injetáveis em toda parte. Medium::Client recebe connection:, Medium::DraftCreator recebe client: e github_client:. Os testes passam fakes diretamente sem tocar em estado global. Isso deixou os testes diretos e rápidos; a suíte inteira desses arquivos novos roda em menos de um segundo.
O que me surpreendeu
O problema do toggle public. Em certo momento eu tinha def index definido abaixo de private no controller, com um reabridor public pra trazer de volta pra fora. Funcionava, o Ruby aceita isso numa boa, mas era estranho de ler. Movi index pra cima de private, onde ele pertence.
URI::regexp versus URI.regexp. O RuboCop pegou uma violação de estilo na minha validação inicial: URI::regexp usa :: pra uma chamada de método, quando deveria ser URI.regexp. Os dois funcionam em Ruby, mas a forma com :: é tecnicamente uma chamada de método, não um acesso a constante, então o RuboCop marca isso sob Style/ColonMethodCall. Correção fácil, mas bloqueou o commit.
A propriedade da máquina de estados levou algumas iterações. Minha primeira versão tinha o DraftCreator definindo medium_import_state: :idle após o sucesso. Isso quebrou o teste do job; o creator falso não definia esse estado, então o teste via running em vez de idle. Mover o reset pra idle pro job resolveu, e na verdade era o design certo: o job iniciou a transição de estado, então o job deveria finalizá-la.
O que vem depois
A funcionalidade de seleção de imagem do Pexels vai apertar a condição do botão de import e prependar a imagem de cabeçalho ao payload HTML. Isso é uma adição limpa; DraftCreator vai receber um parâmetro opcional image_url: e prependar <img src="..."> ao body_html quando presente.
O risco de o Medium desligar a API é real. O ponto de integração é estreito o bastante (Medium::Client tem 40 linhas) que trocar por uma implementação diferente, ou desativar a funcionalidade por completo, mexe em um arquivo só.
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.
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.
O último publisher nativo: fechando a consolidação do Postiz
A integração nativa do Medium era uma peça de museu. Uma ponte de navegador, um artigo cujo corpo é uma URL, e menos 1.603 linhas.
Você também pode achar útil
AdGuard para iOS
Bloqueio de anúncios e rastreadores em todo o sistema no iOS, sem necessidade de um servidor DNS separado.
Como afiliado da AdGuard, 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 maisProton 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