Automação em n8n que monitora licitações públicas no PNCP (Portal Nacional de Contratações Públicas), deduplica contra PostgreSQL, notifica no Telegram apenas o que é novo e registra a saúde da própria execução.
Fluxo exportado em JSON, banco versionado em migrações numeradas, ambiente reproduzível em Docker.
Todo dia às 07:15 (BRT), para cada perfil de busca ativo no catálogo:
- Monta a consulta à API pública do PNCP a partir do catálogo (palavra-chave, UF, modalidade, valor mínimo, janela de dias)
- Chama o endpoint
/v1/contratacoes/publicacaoe classifica a resposta em quatro situações: sucesso, sem resultados, erro de parâmetro e indisponibilidade - Filtra os resultados por palavra-chave e valor mínimo
- Grava no PostgreSQL com
ON CONFLICT DO NOTHING. O banco é quem decide o que é novo - Notifica no Telegram só as licitações que o banco aceitou, com órgão, município, valor formatado, prazo de encerramento e link direto para o edital
- Pagina até acabar, respeitando um teto de segurança
- Fecha o registro de execução com status, contagens e mensagem de erro
Schedule → Config → Start Run → Get Profiles → Loop (batch 1)
↓
Build Request
↓
Dev Mode? ──→ Load Fixture
↓ ↓
Fetch PNCP → Classify Response
↓
┌──────────────┬──────────────┬───────────┴──────┐
ok empty client_error retry
↓ ↓ ↓ ↓
Parse & Filter (segue) Log Failure → Increment Attempt
↓ Notify API Failure ↓
Has Rows? Attempts Exhausted?
↓ ↓ ↓ ↓
true false ──────────────┐ Log Failure → Wait Backoff
↓ │ Notify API Failure ↓
Upsert Procurement │ (reentrada)
↓ │
Aggregate New │
↓ │
Has New Items? ─ false ─────────┤
↓ true │
Format Message │
↓ │
Notify New Items │
↓ │
Mark Notified ─────────────────┤
↓
Has Next Page?
↓ ↓
Next Page Loop Profiles → Finish Run
Schema pncp, o domínio:
| Objeto | Papel |
|---|---|
search_profiles |
catálogo que governa o comportamento: palavra-chave, UF, modalidade, valor mínimo, janela |
procurements |
licitações coletadas; PK é control_number (numeroControlePNCP) |
Schema ops, a operação:
| Objeto | Papel |
|---|---|
workflow_runs |
log de auditoria de cada execução |
v_workflow_health |
leitura da saúde, com fuso convertido e duração calculada |
Migrações sql/001 a sql/006, todas idempotentes. A 006 separa papéis:
o owner escreve, o bi_reader só lê.
O catálogo governa o comportamento. pncp.search_profiles não é uma tabela de
parâmetros: é o que define quantas chamadas serão feitas, com qual recorte e com que
janela. Adicionar um monitoramento novo é um INSERT, não uma alteração no fluxo.
A checagem do que já foi visto mora no banco, e não no n8n. O ON CONFLICT (control_number) DO NOTHING
com RETURNING devolve exatamente as linhas que eram novas. Não há comparação,
cache ou lista de vistos dentro da automação.
Erro é classificado, não tratado em bloco. Um Switch separa 5xx e timeout
(retry com espera crescente) de 4xx (falha imediata, sem retry) e de 204 (sucesso
sem resultados). Tratar tudo como "deu erro" produziria laço infinito num caso e
alarme falso no outro.
Dois mecanismos distintos de falha. Log Failure cobre a falha prevista, a
API fora do ar, capturada pelo Switch. O workflow de erro separado cobre a falha
imprevista, exceção em qualquer nó. Os dois registram em ops.workflow_runs e os
dois notificam no Telegram, por caminhos diferentes: nenhuma falha fica silenciosa.
Modo de desenvolvimento embutido. Um nó Load Fixture reproduz a saída do nó
HTTP a partir de uma resposta real salva em tests/fixtures/. O fluxo inteiro é
construído e testado com a API indisponível, o que neste projeto não foi hipótese.
Identificadores em inglês, comentários e documentação em português. Exceto os
parâmetros da URL (dataInicial, codigoModalidadeContratacao), que são da API.
Segredo fora do repositório. O chat ID do Telegram vem de variável de ambiente, não do JSON versionado.
A API responde HTML quando falha. O 504 vem com content-type: text/html, e um
cliente que assume JSON quebra no parse. O fluxo valida o cabeçalho antes de
parsear. Evidência versionada em tests/fixtures/publicacao_504.html.
204 não é falha. Consulta válida sem resultados devolve 204 No Content, não
200 com lista vazia.
O gateway corta antes do cliente. As falhas se concentram em ~70 s, independentemente do timeout configurado. Timeout no cliente não resolve indisponibilidade no servidor.
Diagnóstico por eliminação vale ser registrado. A hipótese de que o filtro uf
derrubava o endpoint foi testada com 8 chamadas em duas rodadas e refutada:
todas falharam igualmente. O incidente completo está em
docs/incidente-2026-09-02.md: 28 tentativas em 1h23, zero sucessos.
Volume descoberto tarde muda o desenho. 1.503 registros num único dia, para uma única modalidade, obrigaram a reduzir a janela de 3 dias para 1 e a manter o filtro de UF no servidor. Estimar volume antes de dimensionar a paginação teria evitado o retrabalho.
Um nó Filter descarta o item, e junto a informação de controle de fluxo que ele
carregava. Quando não há resultados, o Parse & Filter emite um item sentinela que
carrega _remainingPages. Um Filter descartava esse item, e com ele o sinal que
fechava o ciclo do loop: a execução terminava sem chamar o Finish Run e a linha
ficava presa em running. Onde a decisão precisa continuar viva nos dois desfechos,
o nó certo é o IF, com saída para cada caso.
Nó de agregação com entrada vazia não devolve vazio. Devolve um invólucro vazio. Contar o comprimento da lista não é o mesmo que verificar se há conteúdo. O resultado foi notificação em branco até a condição passar a filtrar por chave.
O workflow de erro do n8n não dispara em execução manual. Só em execução de produção. Durante o desenvolvimento, a exceção aparece apenas no editor.
Codificação é do sistema, não do dado. No Windows, open() do Python lê em
cp1252 e quebra em qualquer acento vindo de API brasileira.
Tirar segredo do repositório tem custo de configuração. O acesso a variáveis de
ambiente vem bloqueado por padrão no n8n; sem N8N_BLOCK_ENV_ACCESS_IN_NODE=false,
quem clonar importa o fluxo e quebra no primeiro nó. O custo está documentado abaixo.
Os quatro caminhos do fluxo foram exercitados:
| Caminho | Evidência |
|---|---|
| Sucesso com novidade | docs/telegram-notificacoes.png |
| Sucesso sem novidade (já visto) | docs/workflow-dedup.png, docs/workflow-health-dedup.png |
| Falha prevista (API fora) | docs/workflow-api-failure.png |
| Falha imprevista (exceção) | docs/telegram-error.png |
O resultado em números, com a primeira execução trazendo 8 novas e 8 notificadas e as duas seguintes com zero:
E a falha real registrada com status HTTP, perfil e URL:
HTTP 504 | TI no Paraná | https://pncp.gov.br/api/consulta/v1/contratacoes/
publicacao?dataInicial=20260902&dataFinal=20260903&...
- O fluxo não roda sozinho. O n8n é local, em Docker. Rodar headless no GitHub Actions exigiria exportar credenciais descriptografadas e guardá-las como secret de CI, risco desproporcional ao ganho. A hospedagem permanente pede um servidor onde o n8n mantenha as credenciais criptografadas no próprio banco.
- Execuções interrompidas deixam registro órfão. A linha em
runningsó é fechada porFinish Runou pelo workflow de erro; cancelamento ou reinício do container deixa o registro preso. A correção natural é aStart Runexpirar execuções antigas, e ela não foi implementada. - Uma execução malsucedida leva ~17 minutos. Três tentativas por perfil, cada uma esperando o corte de 70 s do gateway mais o backoff. Com muitos perfis isso escala mal; faltaria um disjuntor que pule os demais perfis após confirmar indisponibilidade.
- Perfis com mesma modalidade e UF baixam as mesmas páginas mais de uma vez.
A evolução seria buscar por
(modalidade, uf)distintos e aplicar todas as palavras-chave sobre o mesmo lote. - Busca por palavra-chave é
includessimples, sem tolerância a acento ou radical: "licitação" não casa com "licitacao". - Só a modalidade 6 (Pregão Eletrônico) está no catálogo.
- Valor revisado sobrescreve o anterior; não há histórico de revisão.
- Sem testes automatizados sobre paginação e parsing.
- A API do PNCP é instável. Durante o desenvolvimento, o endpoint principal ficou indisponível por vários dias seguidos. O fluxo trata, registra, avisa e segue, mas não coleta.
Crie um PostgreSQL (o projeto usa Neon, plano gratuito) e rode as migrações na ordem:
psql "$DATABASE_URL" -f sql/001_schema_pncp.sql
psql "$DATABASE_URL" -f sql/002_schema_ops.sql
psql "$DATABASE_URL" -f sql/003_views.sql
psql "$DATABASE_URL" -f sql/004_seed_search_profiles.sql
psql "$DATABASE_URL" -f sql/005_modality_name.sql
psql "$DATABASE_URL" -f sql/006_grants.sqlcp .env.example .env
# preencha TELEGRAM_CHAT_IDPara obter o chat ID: crie um bot com o @BotFather, mande qualquer mensagem a ele
e abra https://api.telegram.org/bot<TOKEN>/getUpdates.
docker compose up -dAcesse http://localhost:5678 e:
- Crie a credencial Postgres (SSL: Require) e a credencial Telegram
- Importe
workflows/monitor-licitacoes-pncp-error.json - Importe
workflows/monitor-licitacoes-pncp.json - Em Settings → Error Workflow, aponte para o workflow de erro
- Rode com
modo_dev: truepara testar contra o fixture; mude parafalsepara consultar a API
O docker-compose.yml já define N8N_BLOCK_ENV_ACCESS_IN_NODE=false, necessário
para o nó Config ler o chat ID do ambiente.
sql/ migrações numeradas e idempotentes
workflows/ os dois fluxos exportados em JSON
tests/fixtures/ respostas reais da API (sucesso e erro)
docs/ capturas e registro de incidente
n8n · PostgreSQL · Docker · API REST pública do PNCP · Telegram Bot API
📧 felipemansini@hotmail.com · 💼 LinkedIn · 📔 Portfólio no Notion


