Pular para o conteúdo
Development

Agendamento e compartilhamento de link mais inteligente no meu cross-poster do Postiz

Por Victor Da Luz
railsrubypostizdev-logblog-manager

Um tempo atrás eu conectei meu blog manager (um app pequeno em Rails 8) ao Postiz, um agendador social self-hosted, para poder disparar um post para Bluesky, Mastodon, LinkedIn e o resto com um botão só. Funcionava, mas de forma bruta: postava imediatamente, e o blurb era só o título mais a URL canônica. Sem imagem, sem descrição, sem forma de dizer “na verdade, manda esse amanhã de manhã.” Este é o registro de como resolvi isso, incluindo dois bugs que só apareceram quando fui de fato clicar na UI real.

O que eu estava tentando fazer

Três coisas:

  • Agendar um post para um horário futuro em vez de sempre postar agora.
  • Deixar o blurb mais rico: incluir a descrição do post e anexar uma imagem de thumbnail.
  • Me deixar escolher qual URL é compartilhada, já que a maioria dos posts também vive no Medium e no Dev.to.

Lendo a API antes de escrever qualquer código

A API pública do Postiz não é enorme, mas eu não queria adivinhar o formato das requisições. Fixei minha leitura na versão exata que minha instância roda (v2.21.8) em vez de qualquer coisa que a main tenha hoje, e isso valeu a pena imediatamente: o primeiro arquivo de controller que abri era o errado e não tinha nenhuma rota de upload. O controller de API pública de verdade tinha o que eu precisava:

  • POST /upload-from-url recebe um JSON { "url": "..." } e retorna um objeto de mídia com id e path. É um corpo JSON simples, então meu cliente HTTP existente funcionou como estava, sem lidar com multipart.
  • POST /posts anexa mídia por canal como value[].image: [{ id, path }].
  • type é now | schedule | draft, com date como o horário agendado.
  • GET /find-slot/:id retorna o próximo horário livre para um canal: { "date": "..." }.

Dois minutos lendo o código-fonte de verdade me pouparam de construir um uploader multipart que eu não precisava.

O que eu construí

O agendamento foi a parte fácil assim que o payload ficou claro. O poster agora aceita um scheduled_at opcional; se estiver no futuro, ele envia type: "schedule" com essa data, senão volta a postar agora. O seletor ganhou um campo de datetime e um botão “Sugerir próximo horário livre” que chama find-slot para os canais marcados e preenche o campo.

Para a thumbnail, eu envio a imagem de destaque que o post já tem (uma foto do Pexels que eu já armazeno) via upload-from-url e anexo o id retornado em cada canal. Deixei o upload como best-effort de propósito: se falhar, o post ainda sai como texto em vez de tudo morrer porque uma imagem deu 500.

A descrição também entra no blurb, mas X e Bluesky são curtos, então eu trunco o texto todo para cerca de 280 caracteres, reservando espaço para o título e a URL primeiro e cortando a descrição para caber.

O bug de timezone que eu quase lancei

Esse é o que fico feliz de ter pego antes do merge. O seletor de datetime no navegador entrega uma string de “horário de parede” sem timezone: 2026-06-10T09:00. Meu app Rails estava rodando em UTC (o padrão). Então, se eu escolhesse 9h, o servidor alegremente interpretava como 9h UTC, que são 3h da manhã no meu horário. O post ficaria seis horas errado na fila, e o pior é que o rótulo “Agendado para” renderizava no mesmo UTC, então parecia internamente consistente. Nada na tela me diria que estava errado até o post disparar na hora errada.

Esse é um app de usuário único que eu rodo de um único timezone, então a correção foi uma linha: definir config.time_zone para o meu fuso real. Agora o seletor, o parsing e a exibição concordam entre si, e o banco de dados continua guardando UTC por baixo. Se fosse multiusuário eu teria que fazer a dança correta de offset do navegador, mas não é, então não fiz.

O bug que me fez dizer “não funcionou”

O seletor de link precisava de dados reais para valer a pena testar, então escrevi uma pequena rake task para copiar URLs do Medium do meu banco de produção para o local, casando por blog e slug. Produção é SQLite num volume Docker, então “ler da produção” na verdade significa “puxar um snapshot consistente e ler isso.” Usei VACUUM INTO para gerar o snapshot (sem precisar do CLI do sqlite3 no container), fiz scp para baixo e rodei a importação. Foram reportadas 69 URLs copiadas.

Aí carreguei a UI e… nada. Todo post ainda dizia “Not imported” para o Medium.

Quase comecei a adivinhar, mas me obriguei a ler a view em vez disso. A seção do Medium na página se baseia em medium_status (um enum: not imported / draft / published), não em medium_url. Eu tinha copiado a URL mas deixado o status em “not imported,” então, do ponto de vista da UI, nada tinha mudado. A URL estava tecnicamente ali, só que invisível.

A correção foi copiar o estado de distribuição, não só a URL: medium_url e medium_status juntos (mais alguns campos relacionados). Conferi na produção e, de fato, todos os 69 posts estavam published. Rodei a importação de novo, e os badges acenderam. Óbvio em retrospecto, mas um bom lembrete de que “o dado está na coluna” e “a UI mostra” são duas afirmações diferentes.

O que eu faria diferente e o que vem a seguir

Se eu fosse fazer essa cópia de dados de novo, pensaria no que a UI realmente lê antes de decidir quais colunas copiar, em vez de fixar no único campo que dava nome à tarefa. A questão do timezone eu pegaria mais cedo só perguntando “em que timezone está essa string?” no momento em que uma data cruza a fronteira entre navegador e servidor.

O próximo passo é o receptor de webhook que muda um post de “scheduled” para “posted” assim que o Postiz de fato o publica, para que as URLs publicadas por canal voltem para o app. Por enquanto eu consigo agendar, enriquecer e escolher meu link, e os posts chegam onde e quando eu quero.

Leitura relacionada

Development

Add a feature, or move a responsibility?

Adding Postiz social cross-posting looked done until a blunt question exposed a double-post bug, and a full audit of every posting path in the app found two more like it.

Ler

Você também pode achar útil

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
AdGuard

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 mais
NordPass

NordPass

Gerenciador de senhas da equipe por trás da NordVPN, com um plano gratuito.

Como afiliado da NordPass, ganho com compras qualificadas.

Saiba mais