Scanning de posts pela API do GitHub: o que permissão mínima realmente significa
Consegui colocar o scanning de posts para funcionar no blog-manager hoje. A tarefa: puxar arquivos markdown de um blog Astro hospedado no Git e manter uma tabela Post local sincronizada, para que eu possa depois republicar no Medium e no LinkedIn sem rastrear manualmente o que está onde.
Aqui está o que foi entregue, o que eu decidi, e a parte em que eu mesmo me chamei atenção por estar enrolando.
A forma do problema
O vdaluz.com vive num repositório privado do GitHub. Os posts são arquivos markdown em src/content/blog/, cada um com frontmatter YAML para title, description, pubDate, category, tags. Cerca de 100 posts agora, e crescendo.
O blog-manager precisa saber: quais posts existem, quais são os metadados deles, e quais foram apagados do repositório. Ele não precisa do corpo do texto. Isso é para a etapa de importação no Medium, mais adiante.
Duas formas de pegar os arquivos:
- Clonar o repositório localmente, percorrer o diretório.
- Chamar a GitHub Contents API.
Escolhi a API. Sem binário do git no container LXC, sem cópia de trabalho para manter atualizada, sem tamanho de clone para se preocupar. A Contents API retorna o conteúdo do arquivo codificado em base64 inline para arquivos abaixo de 1 MB, e os posts do blog ficam bem abaixo disso.
O client
Sem a gem octokit. Net::HTTP, porque toda a superfície é dois GETs:
def list_directory(path)
body = get("/repos/#{@owner}/#{@repo}/contents/#{path}")
Array(body)
end
def get_file(path)
body = get("/repos/#{@owner}/#{@repo}/contents/#{path}")
encoded = body["content"].to_s.delete("\n")
decoded = Base64.decode64(encoded).force_encoding("UTF-8")
{ sha: body["sha"], content: decoded }
end
Rails 8.1.3 sobre Ruby 3.3.6. Net::HTTP com use_ssl: true, um timeout de conexão de 5s, um timeout de leitura de 15s, e três erros tipados: AuthError, NotFoundError, RepoMisconfiguredError. Isso é o client inteiro.
Parsing de frontmatter sem gem de parser
Parsing de YAML na stdlib mais uma regex para o bloco --- inicial:
FRONTMATTER_RE = /\A---\s*\n(.*?)\n---\s*\n/m
def parse_frontmatter(content)
match = content.match(FRONTMATTER_RE)
return nil unless match
YAML.safe_load(match[1], permitted_classes: [Date, Time])
end
O permitted_classes: [Date, Time] é a pegadinha. O frontmatter do Astro tem pubDate: 2025-08-16 (data ISO nua), e o YAML.safe_load lança Psych::DisallowedClass nesse caso sem essa allow-list.
A otimização que não custa nada
A resposta do list_directory inclui um sha para cada arquivo (o SHA do blob do git). Se eu guardar esse SHA no registro do Post depois de um scan, o próximo scan pode pular o GET por arquivo quando o SHA bater:
if post.persisted? && post.file_sha == entry["sha"] && !post.discarded?
result.unchanged += 1
next
end
Primeiro scan frio: 1 chamada de listagem mais N chamadas de arquivo. Re-scan morno sem mudanças: 1 chamada de listagem, no total. Para um blog com 100 posts sem alteração, isso é a diferença entre 101 e 1 chamada de API.
Soft delete, mas sem default_scope
Quando um arquivo desaparece do repositório, o Post correspondente recebe um timestamp discarded_at em vez de ser apagado. Isso mantém o histórico (IDs de importação do Medium, IDs de post do LinkedIn) anexado ao registro assim que esses dados chegarem.
Considerei usar default_scope para filtrar posts descartados em todo lugar, e rejeitei a ideia. Se um post volta (o arquivo é readicionado), o scanner precisa encontrá-lo pelo slug, desfazer o descarte, e atualizar seu frontmatter. Com um default scope escondendo as linhas descartadas, esse find_by não encontraria nada e o scanner criaria um duplicado. Em vez disso, dois scopes nomeados (kept, discarded), e as views usam .kept explicitamente.
A questão das permissões
Foi aqui que ficou constrangedor.
Me perguntaram: “como eu consigo um token com o mínimo absoluto de acesso?”
Escrevi uma resposta confiante. PAT de granularidade fina, Contents Read em um repositório, tudo o mais sem acesso. Razoável, mas eu estava enrolando. Eu sabia que estava certo porque tinha lido as notas do spike, não porque tinha verificado na documentação primária.
A cobrança foi justa: “você escreveu o scanner. Vá pesquisar.”
Tentei buscar a página de documentação do GitHub por um summarizer. A seção relevante era sempre removida. A subseção “Fine-grained access tokens” que deveria estar em toda página de endpoint não sobrevivia à conversão para markdown.
Então baixei o HTML renderizado e fiz parsing do blob __NEXT_DATA__ que o site de documentação embute:
data = json.loads(re.search(r'__NEXT_DATA__[^>]*>(.+?)</script>', html, re.S).group(1))
# walk to the "Get repository content" operation
E lá estava, direto da fonte:
{
"fineGrainedPat": true,
"permissions": [{ "\"Contents\" repository permissions": "read" }],
"allowsPublicRead": true
}
Uma permissão. Contents:Read. Nenhuma outra permissão de repositório, nenhuma permissão de conta. Repositórios públicos podem chamar o endpoint sem token nenhum.
Duas lições:
- O blob
__NEXT_DATA__é a fonte de verdade legível por máquina da documentação REST do GitHub. Se eu algum dia voltar a fazer script contra metadados de API, é isso que devo parsear, não o texto renderizado. - Quando eu sei que estou enrolando, fundamentar a resposta em fonte primária desde a primeira vez.
O que vem a seguir
- Mover o scan para um job do Solid Queue. Síncrono funciona bem com 100 posts (menos de 30s), mas prende um worker do Puma durante as idas e voltas e não oferece retry. Vale a pena fazer assim que o Solid Queue estiver configurado. O poller diário de publicações do Medium precisa da mesma estrutura, então eles vão chegar juntos.
- A busca de imagens do Pexels e a importação no Medium são os próximos passos voltados ao usuário. Assim que um post tiver metadados, uma imagem, e o Medium aceitar um rascunho, o ciclo de republicação se fecha.
O total é 571 linhas para a feature mais os testes. Net::HTTP, YAML, ActiveRecord. Nenhuma gem nova. Essa é a régua que eu quero manter nesse projeto.
Leitura relacionada
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.
Apagando código morto, e pegando um motivo errado para uma resposta certa
Uma issue de limpeza com uma justificativa errada, uma varredura de documentação que não era necessária, e a credencial órfã que uma revisão pegou.
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 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 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 mais