ETL e API REST sobre dados públicos do portal de transparência do TCE-TO (Tribunal de Contas do Estado do Tocantins). Faz o scraping diário de licitações, contratos e obras, persiste em PostgreSQL e expõe os dados via Fastify para alimentar painéis de BI.
O portal do TCE-TO tem todos os dados que um analista precisa, mas espalhados em páginas paginadas via POST com payload PHP-serialize+base64, sem API oficial. lupa transforma isso em um banco SQL relacional + endpoints REST que ferramentas de BI (Metabase, PowerBI, Superset) consomem direto.
| Módulo | Origem | Registros | Tabelas |
|---|---|---|---|
| Licitações | /licitacao |
~670 | licitacoes + 4 filhas (documentos, empresas, pregoeiros, contratos_atas) |
| Contratos | /contrato/Index |
~1.191 | contratos + 5 filhas (documentos, aditivos, apostilamentos, pagamentos, responsaveis) |
| Obras | /obraseservicosdeengenharia |
~14 | obras (sem filhas - as 7 abas do detalhe estão vazias hoje) |
Cross-reference entre módulos via colunas *_external_id (sem FK rígida): contratos.licitacao_external_id → licitacoes.external_id, obras.contrato_external_id → contratos.external_id.
- Node.js 20+ / TypeScript 5
- pnpm
- PostgreSQL 16 + Drizzle ORM
- undici (HTTP) + cheerio (parser HTML)
- Fastify 5 (REST API)
- @fastify/swagger + Scalar (docs auto-geradas)
- vitest (testes)
docker compose up -d # postgres em localhost:5433
cp .env.example .env
pnpm install
pnpm db:push # aplica schema (12 tabelas)
pnpm test # 75+ testes de parsers contra fixtures HTML reais
pnpm scrape:licitacao # carga completa (~2:30min)
pnpm scrape:contrato # carga completa (~4:20min)
pnpm scrape:obra # carga completa (~30s)
pnpm api # http://127.0.0.1:3000
# http://127.0.0.1:3000/docs (Scalar UI)Cada CLI aceita --limit N, --page-from N, --page-to N, e --skip-details (licitação/contrato).
REST sob /api/, JSON. Documentação interativa em /docs.
Resumo dos grupos:
health- liveness/readinessmeta- metadados da última carga por módulolicitacoes- lista (filtros: ano, modalidade, situação, valor, data, full-text), detalhe, sub-recursos, statscontratos- lista (filtros: ano, modalidade, situação, unidade gestora, CNPJ, vigência, valor, full-text), detalhe, sub-recursos, statsobras- lista (filtros: ano, situação, empresa, contrato, valor, full-text), detalhe, stats
Medições em ambiente local (Docker postgres):
- Licitações (670): carga limpa 2:33min, idempotente 2:33min (concorrência 4 + bulk upsert).
- Contratos (1.191): carga limpa 4:19min, idempotente 4:16min. Inclui ~10k registros filhos.
- Obras (14): carga limpa ~30s (dominado pela latência do portal nessa rota).
A versão original serial demorava ~12min para licitações; a otimizada (concorrência HTTP + bulk INSERT ON CONFLICT em transação) ficou ~4.7x mais rápida.
Em modo API, ligando SCRAPER_SCHEDULE_ENABLED=true faz o servidor rodar os 3 pipelines em sequência a cada SCRAPER_SCHEDULE_INTERVAL_HOURS (default 24h). Ideal para deploy em Railway/Fly/etc.
src/
api/
plugins/ docs, tag-routes
routes/ health, meta, licitacoes, contratos, obras
server.ts Fastify factory
cli/ entry points (scrape-*, serve)
db/
schema/ 12 tabelas Drizzle
client.ts pool + drizzle factory
scraper/
parsers/ common, portal-helpers, {licitacao,contrato,obra}-{list,detail}
pipelines/ {licitacao,contrato,obra}-pipeline
concurrency.ts pMap + chunkArray
http-client.ts undici com retry exponencial
scheduler.ts cron em-processo para os 3 pipelines
config.ts env carregado e validado com zod
tests/
parsers/ 75+ testes contra fixtures HTML reais
fixtures/ HTML capturado do portal real
Não é GET com ?page=N. É POST com:
dadosfilter: PHP-serialize + base64 do array{inicio, fim}(extraído da página 1)pagina: número da página alvototal: total de registrosordem:pesq
src/scraper/parsers/portal-helpers.ts:parsePaginationForm extrai os campos do <form> que envolve a tabela; o pipeline reaproveita o dadosfilter original em todos os requests subsequentes.
Detalhes técnicos, decisões de modelagem e TODOs ficam em SPEC.md.