Pular para o conteúdo
Development

Scanning de posts pela API do GitHub: o que permissão mínima realmente significa

Por Victor Da Luz
railsrubygithub-apidev-logblog-manager

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:

  1. Clonar o repositório localmente, percorrer o diretório.
  2. 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:

  1. 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.
  2. 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

Você também pode achar útil

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
Proton

Proton 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
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