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
Ensinando o blog-manager a escrever, não só ler, repositórios do GitHub
Um endpoint PUT, uma decisão de mapeamento de erros (409 é o lock otimista, 422 é culpa sua), um PRD que apontava para um arquivo que não existe, e um stash aproveitável numa branch morta.
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.
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.
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