Cross-posting no Hashnode no blog manager
Venho automatizando aos poucos o cross-posting do meu blog. Medium foi o primeiro, depois o Dev.to. Hashnode é o terceiro alvo, e acabou sendo o mais interessante de implementar porque usa GraphQL em vez de REST.
Por que Hashnode
O Dev.to tem uma audiência bruta maior, mas o Hashnode pende mais para o lado de engenharia. Posts com código tendem a ter melhor engajamento por lá. Ele também suporta domínios customizados, o que significa que o leitor cai em username.hashnode.dev, mas a URL canônica ainda aponta de volta para o meu site, exatamente o que eu quero para SEO.
O modelo de autenticação é simples: um Personal Access Token gerado em hashnode.com/settings/developer, passado como header Authorization nas mutations. As queries (usadas para sincronizar o estado de publicação) são públicas, sem necessidade de autenticação.
Sem gem de GraphQL
O blog manager já tinha um padrão para clientes HTTP com conexões injetáveis para teste: tanto Medium::Client quanto Devto::Client recebem um Proc connection: no construtor. Os testes passam uma lambda que retorna uma resposta pronta. Sem WebMock, sem VCR.
GraphQL sobre HTTP é só um POST com um corpo JSON contendo {query:, variables:}. Então mantive o mesmo padrão em vez de trazer graphlient ou graphql-client:
def graphql(query, variables, auth: true)
uri = URI.parse("https://gql.hashnode.com/")
req = Net::HTTP::Post.new(uri)
req["Content-Type"] = "application/json"
req["Authorization"] = @token if auth
req.body = { query: query, variables: variables }.to_json
connect = @connection || Net::HTTP.method(:start)
res = connect.call(uri.host, uri.port, use_ssl: true) { |http| http.request(req) }
handle_response(res)
end
A flag auth: importa aqui. Mutations como createDraft precisam do token, mas a query de listagem usada na sincronização é pública. Passar auth: false omite o header.
O fluxo de publicação em duas etapas
A mutation createDraft do Hashnode cria um rascunho. Não dá para publicar direto via API na mesma chamada, é uma mutation separada, publishDraft. Isso, na verdade, encaixa bem no fluxo desta ferramenta:
- Importar para o Hashnode → chama
createDraft, guarda o ID e a URL do rascunho, muda o status para:draft. Um link “View draft →” aparece na UI. - Revisar no editor do Hashnode: conferir se a imagem de capa renderizou, se a URL canônica está definida, se as tags estão corretas.
- Publicar: ou clicar em “Publish” na UI do blog manager (que chama
publishDraftde forma síncrona), ou publicar direto no editor do Hashnode e deixar o job diário de sincronização pegar isso depois.
Sincronização por slug, não por ID
Essa foi a única decisão de design não óbvia. O job de sincronização chama publication(host:).posts para pegar a lista de posts publicados, e depois faz o match com os rascunhos locais. Eu faço o match por slug, não pelo ID do rascunho.
Por quê? A API de listagem retorna posts publicados. Uma vez que o rascunho é publicado, a relação entre o ID do rascunho e o post fica pouco clara na resposta da API. Slugs são estáveis, não mudam entre a criação do rascunho e a publicação. Então:
remote_slugs = remote_posts.map { |p| p["slug"] }.to_set
@blog.posts
.where(hashnode_status: Post.hashnode_statuses[:draft])
.where(slug: remote_slugs.to_a)
.each { |post| post.update!(hashnode_status: :published, ...) }
Simples. Sem precisar cruzar IDs entre mutations e queries.
Três colunas no Blog, não uma
O Medium precisa de dois campos: token + ID do autor. O Dev.to precisa de um: API key. O Hashnode precisa de três:
hashnode_token: o PAT, criptografado via Active Record Encryptionhashnode_publication_id: um UUID usado nas mutations (criação/publicação de rascunhos)hashnode_publication_host: um hostname comousername.hashnode.dev, usado na query pública de listagem
Essa divisão existe porque as duas operações da API usam identificadores diferentes. As mutations precisam do UUID opaco da publicação. A query pública publication(host:) usa o hostname. Não dá para usar um no lugar do outro.
O anúncio no LinkedIn ainda dispara uma única vez
O serviço Syndication::LinkedInAnnounce roda depois de cada job de sincronização, Medium, Dev.to e Hashnode chamam ele igualmente. Ele encontra posts que estão publicados em qualquer plataforma, mas ainda não anunciados no LinkedIn. A query de elegibilidade usa .or():
@blog.posts
.where(medium_status: :published)
.or(posts.where(devto_status: :published))
.or(posts.where(hashnode_status: :published))
.select { |p| p.linkedin_not_posted? || (p.linkedin_failed? && p.linkedin_attempts < 3) }
Um post publicado nas três plataformas no mesmo dia aparece nesse conjunto uma única vez, o DISTINCT resolve isso no nível do SQL. Então o LinkedIn recebe um anúncio, não três.
O que eu mudaria
A abordagem de três colunas para as credenciais do Hashnode é um pouco estranha de explicar na UI. Um tooltip ajudaria. Também deixei de fora qualquer descoberta automática da publicação, você precisa procurar o ID da sua publicação manualmente. Um equivalente a GET /users/me deixaria a UI buscar isso automaticamente ao salvar o token, mas isso é um extra, não uma prioridade.
O próximo alvo de sindicação na minha lista é newsletter (Substack ou ConvertKit). Isso provavelmente vai significar repensar a UI da linha por post. Ela já está ficando larga.
Leitura relacionada
Apagando uma integração de publicação que eu tinha acabado de construir
Removendo a integração nativa com o Hashnode que nunca foi usada: 31 arquivos, +14/-1126, um grep que mentiu um pouco, e a pergunta de arquitetura que a remoção forçou.
Uma correção de desvio de documentação que não foi tão chata quanto parecia
Três itens da auditoria que cada um virou outra coisa: uma alegação meio corrigida, uma recuperação de senha silenciosamente morta, e um e-mail de staging que apontaria para produção.
Um 500 escondido dentro das rotas isoladas de uma engine montada
O painel de jobs retornava um 500 em vez de uma página de login: helpers de rota sem qualificação resolvem contra a engine, não contra a aplicação. Uma linha, mais o gêmeo dormente dela.
Você também pode achar útil
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 maisProton Mail
E-mail criptografado de ponta a ponta, com arquitetura de acesso zero.
Como parceiro da Proton, ganho com compras qualificadas dos serviços de privacidade e segurança da Proton (Pass, Mail, VPN, Drive).
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