Pular para o conteúdo
Development

Adicionando o Dev.to como um alvo de syndication

Por Victor Da Luz
railsrubydevtodev-logblog-manager

Eu já tenho um app Rails que importa posts para o Medium e dispara um anúncio no LinkedIn assim que eles são publicados. Adicionar o Dev.to parecia o próximo passo óbvio, já que é onde boa parte do público de desenvolvedores realmente está. Imaginei que seria rápido, já que a arquitetura já existia.

E foi, na maior parte. Mas algumas coisas me surpreenderam.

A API é simplesmente melhor

A API do Medium está depreciada. Preciso usar um token que expira, fazer polling num feed RSS para detectar quando um rascunho é publicado, e injetar tags <figure> para imagens de destaque porque a API não tem um campo main_image. A coisa toda se sustenta com parsing de string.

O Dev.to é o oposto. A API do Forem é documentada, mantida ativamente, e faz o que você esperaria: POST /api/articles cria um rascunho, PUT /api/articles/{id} atualiza ele, GET /api/articles/me/all retorna sua lista completa de artigos com timestamps de published_at. Sem RSS. Sem scraping. A imagem de destaque é só um campo no payload.

A autenticação também é mais simples: um único header api-key das configurações da sua conta, armazenado como uma coluna criptografada no model Blog.

Espelhando a arquitetura do Medium

Minha integração com o Medium usa um padrão em que todo service que faz chamadas HTTP recebe um Proc connection: que retorna uma resposta falsa nos testes. Isso me permite testar os ramos de erro (401, 429, 5xx) sem WebMock ou VCR, basta passar uma lambda que retorna um Struct.new(:code, :body).

Mantive o mesmo padrão para o Devto::Client:

def initialize(token:, connection: default_connection)
  @token      = token
  @connection = connection
end

def create_article(payload:)
  response = request(Net::HTTP::Post, "/api/articles", payload)
  raise AuthError, "..." if response.code == "401"
  JSON.parse(response.body)
end

O resto da camada de service espelha o Medium quase por completo: DraftCreator, Publisher, PublishedSync. A principal diferença é que PublishedSync substitui tanto o RssPoller quanto o PublishedBackfill do Medium, porque a API retorna todos os artigos com seu published_at, então não há nada para fazer backfill separadamente.

O refactor do anúncio no LinkedIn

Assim que passei a ter duas plataformas de syndication, a lógica de anúncio no LinkedIn precisou morar em algum lugar compartilhado. Antes ela estava inline no MediumSyncJob, um método privado que checava expiração de token, contagem de retries e idempotência.

Eu extraí ela para Syndication::LinkedInAnnounce, um PORO pequeno que recebe um blog e itera sobre os posts elegíveis:

def eligible_posts
  @blog.posts
       .where(medium_status: Post.medium_statuses[:published])
       .or(@blog.posts.where(devto_status: Post.devto_statuses[:published]))
       .select { |p| p.linkedin_not_posted? || (p.linkedin_failed? && p.linkedin_attempts < 3) }
end

A query .or() significa que um post publicado em qualquer uma das plataformas dispara o LinkedIn. O select cuida da idempotência: se o post já tem linkedin_status: :posted, ele é pulado. Um post publicado tanto no Medium quanto no Dev.to só é anunciado uma vez.

Tanto o MediumSyncJob quanto o DevtoSyncJob agora chamam announcer_class.new(blog).call, onde announcer_class é um class_attribute com valor padrão Syndication::LinkedInAnnounce, injetável nos testes sem nenhuma mágica de stubbing.

A armadilha do Zeitwerk

Nomeei a classe como Syndication::LinkedinAnnounce (“in” minúsculo). Parecia natural. Os testes quebraram na hora com NameError: uninitialized constant Syndication::LinkedinAnnounce.

O problema: config/initializers/inflections.rb tem inflect.acronym "LinkedIn". O Zeitwerk usa o inflector do Rails para mapear nomes de arquivo para nomes de constante, então linkedin_announce.rb é mapeado para LinkedInAnnounce, não LinkedinAnnounce. O nome da classe e a constante esperada divergiram silenciosamente.

A correção foi renomear a classe para Syndication::LinkedInAnnounce. Vale saber: qualquer arquivo de service cujo nome contenha um acrônimo registrado (OAuth, GitHub, AWS) precisa usar a forma inflexionada, ou o Zeitwerk não vai encontrar.

Testes

As mesmas convenções de Minitest do resto do app: sem WebMock, sem VCR. Os testes do job usam injeção via class_attribute:

setup do
  @original_sync      = DevtoSyncJob.sync_class
  @original_announcer = DevtoSyncJob.announcer_class
end

teardown do
  DevtoSyncJob.sync_class      = @original_sync
  DevtoSyncJob.announcer_class = @original_announcer
end

Salva o original, injeta um fake, restaura no teardown. Funciona bem com execução paralela de testes porque cada processo de teste recebe sua própria instância de classe.

O teste de idempotência é explícito: um post publicado nas duas plataformas deve aparecer só uma vez no log de chamadas do announcer:

test "idempotent: a post published on both Medium and Dev.to is only announced once" do
  post = @blog.posts.create!(slug: "p", medium_status: :published, devto_status: :published, ...)
  calls = []
  Syndication::LinkedInAnnounce.new(@blog, creator_class: creator_class(calls)).call
  assert_equal 1, calls.count { |p| p.id == post.id }
end

O que eu faria diferente

A sanitização de tags (gsub(/[^a-z0-9]/i, "").downcase) é tosca. “C++” vira “c”, “C#” vira “c”. O Dev.to tem restrições de tag (minúsculas, alfanuméricas, no máximo 4), mas eu deveria tratar melhor os casos comuns, ou pelo menos logar quando uma tag fica deformada, para deixar isso visível. Hoje isso falha silenciosamente.

Também deixei de lado a descoberta de username. A API tem GET /users/me, que permitiria guardar devto_username no model Blog. Deixei isso de fora por enquanto, já que a URL do artigo já volta na resposta de criação de qualquer forma.

Próximos passos

Hashnode está na lista. Mesmo padrão, formato de API diferente. Nesse ponto a arquitetura de syndication já está estável o bastante para que adicionar uma terceira plataforma seja praticamente só somar.

Leitura relacionada

Você também pode achar útil

Proton

Proton Pass

Gerenciador de senhas focado em privacidade, da equipe por trás do Proton Mail.

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

Saiba mais
Proton

Proton Drive

Armazenamento em nuvem criptografado, da equipe por trás do Proton Mail.

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

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