Construindo imagens de destaque auto-hospedadas antes de eu ter qualquer coisa que precisasse delas
Peguei isso principalmente como trabalho de encanamento: o blog-manager tem uma integração com o Pexels que permite buscar uma foto de banco de imagens e deixá-la pronta como imagem de destaque de um post, mas a URL que ele guarda é um hotlink direto para o CDN do Pexels. Isso é tranquilo para o Pexels, já que os termos deles preferem hotlinking mesmo. Não é tranquilo para o Openverse, a busca de imagens com licença CC que eu quero adicionar em seguida, porque o Openverse só faz proxy de buscas entre Wikimedia, Flickr e servidores aleatórios de museus, qualquer um dos quais pode dar 404 ou começar a bloquear hotlinking quando bem entender. Licenças CC permitem explicitamente baixar e re-hospedar, então a solução é realmente baixar a imagem e fazer commit dela no repositório do blog, como toda outra imagem de destaque que já está lá.
Uma sessão anterior já tinha resolvido as duas questões de escopo em aberto (backend de armazenamento, formato do pipeline de download) vasculhando o próprio repositório do vdaluz.com, então fui direto para a construção.
O que eu construí
Um serviço HeroImage::Downloader: busca os bytes de uma URL, rejeita qualquer coisa que não seja image/* ou que passe de 10MB, segue até 3 redirecionamentos (muitos hosts de imagens CC redirecionam), e normaliza para JPEG com qualidade 82 usando ruby-vips:
::Vips::Image.new_from_buffer(body, "").jpegsave_buffer(Q: JPEG_QUALITY)
Depois, uma flag por fonte em Post, para que o comportamento de auto-hospedagem seja opt-in por provedor, não um interruptor global:
HERO_IMAGE_SOURCE_INFO = {
"pexels" => { name: "Pexels", url: "...", self_host: false },
"unsplash" => { name: "Unsplash", url: "...", self_host: false },
"openverse" => { name: "Openverse", url: "...", self_host: true }
}.freeze
E o HeroImage::RepoCommitter ganhou um segundo caminho de commit. A Contents API do GitHub só aceita um arquivo por PUT, então uma imagem de destaque auto-hospedada agora custa dois commits em vez de um: primeiro os bytes da imagem chegam em public/assets/images/<slug>.jpeg, depois o commit de frontmatter que já existia roda, exceto que agora ele escreve o caminho relativo ao site, auto-hospedado, em heroImage em vez da URL bruta do provedor.
Decisões
Deixei de lado a gem image_processing, mesmo já estando parada sem uso no Gemfile. O backend Vips dela quer um caminho de arquivo (ela chama Vips::Image.new_from_file), então usá-la aqui significaria escrever os bytes baixados em um arquivo temporário só para passar um caminho. O Vips::Image.new_from_buffer puro faz o mesmo trabalho direto da memória. Como eu não estou redimensionando nada, só reconvertendo o formato, a abstração extra não trazia nenhum ganho.
Também quase usei o get_file do cliente de conteúdo do GitHub para buscar o sha de uma imagem existente e fazer sobrescritas idempotentes, mas aí percebi que ele força a resposta para UTF-8:
decoded = Base64.decode64(encoded).force_encoding("UTF-8")
Isso é tranquilo para markdown, não é tranquilo para bytes de JPEG. list_directory retorna metadados do arquivo (nome, sha, tamanho) sem tocar no conteúdo, então usei isso no lugar para encontrar o sha de um arquivo existente antes de sobrescrever.
O que me surpreendeu
Eu queria verificar o pipeline de download contra algo mais próximo do que o Openverse vai realmente fazer proxy, então testei com uma URL real do Wikimedia Commons. Recebi um 400, depois um 403 mesmo com um User-Agent descritivo. Não persegui isso mais porque não é algo que eu controlo, e os endpoints de teste /image/jpeg e /image/png do httpbin cumpriram o mesmo papel de provar que uma busca de rede real funciona de verdade e realmente converte os formatos.
A surpresa maior veio nos testes manuais, e não tinha nada a ver com o meu código. A fila de “imagem de destaque ausente” no blog-manager é orientada por uma coluna do banco de dados (live_hero_image_url) que só é atualizada ao escanear o repositório, e esse blog não era escaneado havia mais de um mês. Um post tinha uma imagem de destaque ativa no frontmatter que o banco ainda não conhecia, então minha nova proteção de inserção única corretamente recusou sobrescrevê-la, o que parecia um bug mas na verdade era a proteção fazendo o trabalho dela contra dados desatualizados. Rodar um escaneamento novo para corrigir isso levou cerca de quatro minutos e pareceu travado o tempo todo. Descobri que o Solid Queue simplesmente não te dá uma barra de progresso, e o sinal real de que não estava travado foi ver os timestamps de Post#updated_at avançando em tempo real conforme ele passava pelos 166 posts, dos quais 135 estavam desatualizados.
Esse mesmo re-escaneamento também expôs uma lacuna de UI real e pré-existente: o card de atribuição de imagem de destaque só verifica a coluna hero_image_url em preparação, não o que está realmente ativo, então qualquer post cuja imagem de destaque foi definida fora do seletor de Pexels do próprio blog-manager (existe um script separado que faz isso diretamente) aparece como se não tivesse imagem de destaque nenhuma. Registrei isso separadamente em vez de deixar essa tarefa crescer para além do escopo.
Próximos passos
Essa capacidade ainda não faz nada sozinha, já que nada produz uma fonte openverse. O provedor Openverse é o que realmente vai colocá-la à prova de verdade. Até lá, ela só tem cobertura automatizada: testes unitários em torno da sequência de dois commits e do comportamento de sobrescrita idempotente, além daquele único round-trip de rede real contra o httpbin provando que a etapa de busca e normalização não é só mocks do início ao fim.
Leitura relacionada
O bug da imagem de destaque que na verdade eram três bugs
Um card que mentia sobre um hero ausente, um caminho relativo resolvido contra a origem errada, e um bloqueio de hotlink do meu blog contra o meu próprio app.
Adicionando o Unsplash como um segundo provedor de imagem de destaque
Espelhando o client do Pexels linha por linha, um ping de rastreamento dispara-e-esquece, e uma URL do navegador que quase acabou com a chave de API anexada.
O que acontece quando um job faz broadcast para ninguém
Fechando o loop da imagem de destaque: frontmatter só por inserção, uma guarda que pegou divergência real na primeira execução, e um broadcast sem ouvinte.
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 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