← Voltar Null Comma Jogos, protótipos e insights de desenvolvimento

Resultados 2026 em tempo real - Engenharia reversa dos endpoints do TSE

Imagem de capa de Resultados 2026 em tempo real - Engenharia reversa dos endpoints do TSE

Nas eleições de 2026 eu quis uma coisa bem simples: abrir uma URL e ver a apuração subindo na tela, do jeito que a TV mostra. O TSE já publica os dados oficiais em JSON, mas em um site oficial desenhado para consulta pontual, não para ficar aberto na aba a noite inteira enquanto os números se movem.

Este é o registro de como eu construí um visualizador independente em cima dos dados públicos do Tribunal (e, principalmente, de onde saíram os endereços dos arquivos). Ele cobre todo o Brasil, por estado e por município, e está no ar em https://apuracao.nullcomma.com.

Interface do visualizador no tema claro Captura real da interface no tema claro, já com dados da apuração.


Descobrindo os endpoints: o bundle Angular oficial

Não existe documentação pública para a API do resultados.tse.jus.br. Em vez de chutar nomes de arquivo e códigos de eleição, peguei o bundle JavaScript publicado pelo site oficial do TSE e procurei os literais de URL. É assim que os caminhos de template aparecem no código, com uf, cargo e eleicao interpolados pela aplicação Angular.

Os códigos de eleição saíram do config-mestre do ciclo, em oficial/comum/config/ele-c.json, que é o catálogo de eleições. A eleição de 2026 tem duas entradas que importam:

códigoescopopasta de dadoscargos
6257federaldados/brPresidente
6259estadualdados/<uf>Governador, Senador, Dep. Federal, Dep. Estadual

O nome do arquivo é um template com preenchimento por zero à esquerda: cargo com 4 dígitos, eleição com 6.

/oficial/ele2026/<eleicao>/dados/<pasta>/<pasta><mun>-c<cargo>-e<eleicao>-u.json

Na prática, os cinco cargos que o app mostra são:

cargocódigo eleiçãoid do cargoexemplo (Paraná)
Presidente625716257/dados/br/br-c0001-e006257-u.json
Governador625936259/dados/pr/pr-c0003-e006259-u.json
Senador625956259/dados/pr/pr-c0005-e006259-u.json
Dep. Federal625966259/dados/pr/pr-c0006-e006259-u.json
Dep. Estadual625976259/dados/pr/pr-c0007-e006259-u.json

Um detalhe que só aparece quando alguém olha o caminho com calma: Presidente em âmbito estadual não fica na pasta do estado, e sim em br. Já os demais cargos seguem a pasta da UF. Presidentes em âmbito municipal, ao contrário, usam a UF no caminho:

  • Governador em Campinas/SP (62910): 6259/dados/sp/sp62910-c0003-e006259-u.json
  • Presidente em Curitiba/PR (75353): 6257/dados/pr/pr75353-c0001-e006257-u.json

Ou seja: no escopo municipal, o código do município entra como prefixo do nome do arquivo e a pasta é sempre a UF, qualquer que ela seja, inclusive para Presidente.

A lista completa de municípios vem de outro config, 6259/config/mun-e006259-cm.json, e sai de lá com o total de 5.571 municípios do Brasil. É o dicionário que alimenta a busca.

As fotos dos candidatos saem do mesmo host, num terceiro caminho: oficial/ele2026/<eleicao>/fotos/<uf>/<sqcand>.jpeg, com br para Presidente e a UF do estado nos demais cargos. O <sqcand> é o identificador sequencial que vem em cada candidato no JSON, então foto e candidato ficam amarrados pelo mesmo número. São as imagens oficiais do TSE, e o app só as repassa pelo próprio proxy: nenhuma foto vem de rede social ou de recorte de terceiro.

O formato do JSON e o que eu extraí

O arquivo vem aninhado em quatro níveis, e vale registrar porque cada nível tem um significado político, não só estrutural:

carg → agr (agrupamento / coligação) → par (partido) → cand (candidato)

Do candidato eu leio sqcand (a chave da foto), n (número na urna), nmu com fallback para nm (nome de urna e nome completo), vap (votos), pvap (percentual, que vem como "33,82": string com vírgula, não número), e os campos de resultado e e st (o e engana; veja a seção sobre quem foi eleito, adiante). Da coligação, agr.nm; do partido, par.sg para a sigla.

Do bloco de progresso, que é o que mais importa para saber se o número é confiável: s.ts e s.st (seções totais e totalizadas), s.pst (percentual totalizado), e.te (eleitorado), e.est (eleitorado totalizado), v.tvn (votos válidos) e v.tv (total de votos). dg e hg dão a data e a hora de geração do arquivo.

pvap me deu um bug silencioso na primeira versão: eu parseava direto e recebia zero em todos os candidatos, sem erro nenhum. Parsear string com vírgula como se fosse número não estoura; só devolve NaN.

Quem foi eleito, e a armadilha do campo e

O campo óbvio para saber quem ganhou é e, que a própria documentação do TSE cita (ele “vem preenchido com ‘n’ desde a primeira geração do arquivo”). Eu ia usá-lo direto. Foi medindo contra os arquivos já fechados que ele se mostrou traiçoeiro: e = "s" não quer dizer “eleito”, quer dizer “não foi eliminado”. No Distrito Federal, os dois primeiros colocados para governador vinham com e = "s" e st = "2º turno". Quem só se classificou ao segundo turno recebe o mesmo "s" de quem ganhou no primeiro.

O campo que responde de verdade é st, a situação em texto. Medindo os arquivos já fechados, ele assume cinco valores: "Eleito", "Eleito por QP" e "Eleito por média" (vencedor no primeiro turno), "2º turno" (classificado) e "Não eleito". A regra do app ficou: st começando com “Eleito” vira o selo ELEITO; st contendo “turno” vira o selo 2º TURNO. O campo e é ignorado para decidir o selo.

O TSE só preenche st quando fecha a apuração daquele cargo, e nesse momento o topo do arquivo passa a trazer and = "f". Enquanto a contagem corre, st está vazio e nenhum selo aparece. Não é bug: é o Tribunal não ter fechado ainda.

Uma pegadinha de escopo: o arquivo de Presidente por UF fecha (and = "f") mas não traz st nenhum. A decisão do Presidente é nacional, então o recorte estadual não marca ninguém. Quem marca é o arquivo nacional, 6257/dados/br/br-c0001-e006257-u.json.

Na tela isso virou duas coisas pequenas: um selo verde ELEITO (ou âmbar 2º TURNO) no card, e o cabeçalho do cargo passando a dizer “apuração encerrada”. O toque de confirmação da urna só dispara para ELEITO, nunca para quem vai ao segundo turno.

O servidor: um server.js sem dependências

O backend inteiro é um arquivo só, com zero dependências externas: só http, fs, path, crypto e o fetch global do Node. Ele faz três coisas: normalizar o JSON do TSE em um formato estável para o front, servir uma API de snapshot, e fazer proxy das fotos.

A normalização achata os três níveis de aninhamento em uma lista de candidatos e já resolve o caminho da foto, para o front nunca precisar montar URL de terceiro:

// server.js - a parte que decide o caminho do arquivo
function fileFor(race, uf, mun) {
  const folder = (race.nivel === 'federal' && !mun) ? 'br' : uf;
  const prefix = mun ? String(mun) : '';
  const c = String(race.cargo).padStart(4, '0');
  const e = String(race.eleicao).padStart(6, '0');
  return `/oficial/${CICLO}/${race.eleicao}/dados/${folder}/${folder}${prefix}-c${c}-e${e}-u.json`;
}

A API é enxuta:

  • GET /api/snapshot?uf=sp&mun=<cd>: os 5 cargos daquele local (sem mun, o estado inteiro)
  • GET /api/municipios: os 5.571 municípios e a lista de UFs
  • GET /api/reverso?lat=&lon=: cidade aproximada a partir de coordenadas
  • GET /api/geoip: cidade e UF aproximadas pelo IP do visitante
  • GET /api/foto?e=6259&uf=pr&id=<sqcand>: proxy da foto do candidato
  • GET /api/version e GET /api/health: build id e batida de saúde

O snapshot busca os cinco cargos com Promise.allSettled, e não com Promise.all. A diferença importa na prática: durante a apuração o TSE falha com 404 em arquivos que ainda não publicaram, e com Promise.all um cargo ausente derrubaria a tela inteira. Com allSettled, o que falta vai para um array erros e o resto aparece normalmente.

Depois que li isso, notei que o array RACES é a única tabela de configuração que a aplicação tem: código de eleição, cargo, nível (federal ou estadual), nome e número de vagas saem dali, e o resto do servidor é genérico.

Duas defesas pequenas que valem mencionar: o caminho do arquivo estático é normalizado e conferido antes da leitura, para que um caminho relativo não escape da pasta pública, e todo parâmetro de entrada é validado por regex antes de virar caminho: uf com /^[a-z]{2}$/, mun com /^\d{5}$/, e o id da foto com /^\d{6,}$/.

Quase-tempo-real: onde está o teto

O cliente faz polling a cada 2 segundos (ajustável de 2 a 30), e o servidor mantém um cache em memória de 1,2 segundo por URL. Cada requisição ao TSE também leva um nocache=<timestamp> na query, senão um CDN intermediário me entrega o JSON velho e o app parece quebrado sem estar.

E aqui é preciso ser honesto com quem lê: o app não é mais rápido que a fonte. Nenhuma técnica de polling, cache ou proxy cria um voto que o TSE ainda não publicou. O teto é a cadência de divulgação do Tribunal. O que o cache e o proxy compram é outra coisa, e isso é real: 40 pessoas assistindo Curitiba no mesmo endereço resultam em uma requisição ao TSE por 1,2 segundo, em vez de 40.

Por isso o polling padrão é de 2 segundos e não 500 ms: mais rápido seria só carga em cima de um servidor público que eu não controlo.

Antes das 17h não existe nada para mostrar. A tela diz exatamente isso, em vez de exibir um zero silencioso que parece resultado.

A interface

Mobile-first de verdade: coluna única com largura máxima de 760 px, viewport-fit=cover, chips de cargo que quebram linha, e alvos de toque grandes. Tema claro por padrão com alternância para escuro (botão ◐, preferência salva no navegador).

Interface do visualizador no tema escuro O mesmo layout no tema escuro. As fotos são as oficiais, servidas via proxy.

Interface no desktop (web normal) No desktop (web normal): o widget fixo do Brasil no topo, a cor do app seguindo o líder do cargo e a lista em ordem de votação.

Cada candidato aparece com a foto oficial e a cor do partido na borda e na barra de votos. Além disso, a cor do próprio app (header, chip ativo, barras) assume a cor do partido de quem lidera o cargo selecionado, com o texto do header trocando entre preto e branco por luminância relativa para não ficar ilegível. Sem votos ou em empate, volta ao verde padrão. Os cards mantêm a cor do partido de cada candidato, então a barra de progresso e a identidade de quem está na frente continuam legíveis mesmo com o app inteiro na cor do líder.

No topo, logo abaixo do header, fica um widget fixo com os dois presidentes mais votados, sempre atualizando. Quando a aba de Presidente está selecionada o widget some, para não duplicar a mesma informação.

Busca de cidade e localização automática

Um único campo de texto busca os 5.571 municípios de todo o Brasil, ignorando maiúsculas e acentos, e mostra no máximo 8 sugestões com a sigla da UF ao lado (Bom Jesus · GO, Curitiba · PR), porque nome de cidade repete muito no país e a sigla é o que desambigua. Digitar o nome de um estado também oferece Todo o <estado>, que é a visão estadual.

O casamento é aproximado em três degraus, com pontuação: começa com o texto (100), contém (60), ou é subsequência dos caracteres: ctba acha Curitiba (20). Tudo normalizado com NFD antes de comparar.

A localização automática tenta o GPS do dispositivo e cai para a localização por IP se for negada. Coordenada → cidade vem de geocodificação reversa no Nominatim; o caminho por IP é uma consulta simples. A escolha fica salva, então ninguém pergunta duas vezes.

Reordenar sem piscar: DOM keyed + FLIP

Este foi o detalhe de front-end que mais importou. A cada 2 segundos a lista reordena sozinha, porque os votos mudam. A implementação ingênua (recriar a lista inteira) faz a foto de todo mundo recarregar a cada atualização, e o resultado é uma interface que pisca o tempo todo mesmo com os números subindo.

A solução foi manter um Map de nós do DOM indexado pelo número do candidato, reaproveitando cada card. Quando o candidato reaparece, o mesmo elemento é atualizado no lugar; a foto tem o src atribuído uma vez e nunca mais. Isso resolve o piscar sozinho.

O movimento dos cards usa a técnica FLIP: antes de reordenar, medir o getBoundingClientRect().top de cada card; reordenar no DOM; medir de novo; aplicar o deslocamento inverso com translateY sem transição; e no requestAnimationFrame seguinte, soltar a transição para translateY(0). O navegador anima do ponto antigo ao novo, e a lista parece deslizar em vez de saltar.

Chips de cargo também são reaproveitados por assinatura: se a lista de ids não mudou, não se reconstrói nada. É a mesma economia de trabalho, um nível acima.

Um detalhe de sono: ele toca quando o primeiro lugar do cargo selecionado troca, comparando numero|partido com o líder anterior, e vem desligado por padrão. O botão 🔊 no header liga, e o clique também serve para desbloquear o áudio: todos os navegadores exigem um gesto do usuário antes de tocar áudio, então o clique resolve as duas coisas de uma vez.

O som é um WAV de 440 Hz sintetizado por um script próprio, tools/gen-sounds.js, que escreve o cabeçalho RIFF byte a byte e monta a onda somada com uma senoidal sob envelope exponencial. Nenhuma biblioteca, nenhuma amostra de terceiros: o ponto era não depender de um arquivo de áudio externo que pudesse sumir do deploy.

E há a Wake Lock API, para a tela não apagar no meio da apuração: ligada por padrão, com um botão para desligar, e o lock é re-adquirido quando a aba volta a ficar visível. Navegador sem suporte, vira no-op silencioso.

Operação no meio da apuração

Dois problemas práticos que só aparecem quando o app está no ar às 17h de um domingo.

O primeiro é corrigir código com gente olhando. O servidor calcula um build id (um hash curto do binário e do front) e o expõe em /api/version, /api/health e dentro do próprio snapshot. O cliente guarda esse valor; quando ele muda, a página salva a posição do scroll, espera 700 ms e se recarrega sozinha, pegando o código novo. Quando o build não muda (um crash e restart, por exemplo), não há reload nenhum: a tela não pisca à toa no meio da apuração.

O reload preserva o que importa. A aba selecionada fica no localStorage, e o scroll vai para o sessionStorage no evento pagehide, o que faz ele sobreviver também a F5 e navegação normal, não só ao reload automático. Depois de voltar, a restauração do scroll repete até 14 vezes a cada 250 ms, porque no primeiro quadro o layout ainda pode não ter a altura final.

O segundo problema é o endereço. Um túnel rápido do Cloudflare gera uma URL aleatória a cada subida, o que é ótimo para testar e péssimo para quem vai guardar o link. A saída foi fixar o nome: o endereço deixou de ser sorteado a cada boot e passou a ser sempre o mesmo. O fluxo de hotfix é editar, subir de novo, e o link não muda.

Para o deploy, o mesmo processo serve a API e o front, então é um container só: nada para instalar no servidor, escutando apenas na interface local, com um HEALTHCHECK batendo em /api/health.

Onde ele roda hoje: do túnel no PC a um servidor pequeno

A primeira versão ficou no ar no meu PC, atrás do túnel nomeado, e aguentou a noite. Terminada a apuração, a pergunta virou onde isso deveria morar. A resposta foi um servidor pequeno, de 1 GB de RAM, que já estava lá e agora divide a máquina com o resto.

O detalhe que importa é como o app chegou lá. A imagem Docker é construída aqui, onde a máquina é forte, e só o artefato viaja: docker save, scp, docker load. O servidor nunca instala Node, nunca roda npm install, nunca compila nada. O compose de lá tem duas linhas que interessam: a imagem e a rede interna que o proxy já usa.

Medido no servidor: o container ficava entre 30 e 40 MB de RAM, com CPU em repouso perto de zero. A exposição é um proxy reverso que aponta o subdomínio para o container e resolve o TLS.

O túnel do PC saiu de cena. Ele segue documentado como rollback, mas a origem agora é o servidor.

Depois disso veio a reescrita em Go. O servidor é stdlib puro, então o port foi direto, e a comparação lado a lado dos dois (mesmo JSON, nos mesmos 7 escopos, ordem da lista inclusive) fechou idêntica. O que mudou foi o peso: a imagem caiu de 238 MB para 10,4 MB, o que viaja na transferência caiu de 58 MB para 3 MB, e a RAM do container ficou entre 12 e 22 MB em repouso. Numa máquina de 1 GB que ainda vai receber outros serviços, é essa diferença que conta. O server.js continua no repositório como referência e como caminho de volta.

O primeiro dia em produção trouxe um susto que vale registrar: o container começou a reiniciar sozinho, 28 vezes, com o kernel matando o processo por memória. A causa não era o tamanho do serviço, era o cache. Eu guardava o JSON do TSE já parseado em memória, num mapa sem teto, e um arquivo de 500 KB vira uns 5 MB depois de virar estrutura de dados. Um rastreador varrendo os 853 municípios de Minas Gerais criava uma entrada nova a cada município, e com o limite de 64 MB do container o resultado foi um loop de OOM. A correção foi guardar o corpo cru com teto de bytes e evicção LRU, deixar o parse por requisição e limitar o heap do runtime. Medido depois, com o mesmo rastreador batendo: 31 MB e zero reinício.

Limites honestos

Some as três coisas que este app não é:

  • Os dados existem quando o TSE publica. Não há como antecipar um voto, e antes das 17h a tela está vazia por motivo.
  • Não somos mais rápidos que a fonte. O polling de 2 segundos é só o meio mais barato de refletir a cadência de divulgação; reduzir o intervalo só aumenta a carga sobre um servidor público.
  • As cores de partido são apoio visual aproximado, um mapa de cores que eu montei para dar leitura rápida à barra. Não é identidade oficial de partido, e não deve ser tratado como marca registrada.

Os dados são oficiais do TSE. Este é um visualizador independente, sem qualquer vínculo com o Tribunal Superior Eleitoral.


Se você quiser ver o formato dos arquivos que eu li para montar isso, ele é a mesma estrutura em todos os estados e municípios: resultados.tse.jus.br/oficial/ele2026/ mais o caminho da tabela do começo. Qualquer editor de texto e um curl resolvem o resto.