DEV Community

Eduardo Yuji Matheus for Brasil Utils

Posted on AI-assisted

CNPJ e CEP em lote a partir de planilha: 4 armadilhas (e um script Python testado)

TL;DR (English): Bulk-validating Brazilian company IDs (CNPJ) and postal codes (CEP) from a spreadsheet has four traps: Excel strips leading zeros (so 191 may be a CNPJ, not a CEP), check digits must handle the new alphanumeric CNPJ, free public APIs have rate limits you should respect, and you must keep one output row per input. Below is a tested Python script using free public APIs (BrasilAPI, Minha Receita, ViaCEP). At the end we mention our paid Apify Actor that does the same at US$0.002 per found lookup. The script works without it.

Transparência: este post é da Brasil Utils. Nós criamos a ferramenta paga citada na última seção, mas todo o código aqui funciona sem ela, usando APIs públicas e gratuitas.

Quem mexe com cadastro de fornecedores, CRM ou ERP no Brasil conhece a cena: chega uma planilha com milhares de CNPJs e CEPs "para validar". Parece só chamar uma API em loop. Aqui estão as quatro armadilhas que encontramos no caminho, com um script que testamos contra as APIs públicas.

Armadilha 1: 191 é CNPJ ou CEP?

O Excel trata CNPJ e CEP como número e apaga os zeros à esquerda. O CNPJ do Banco do Brasil, 00.000.000/0001-91, vira 191, e o CEP 01001-000 vira 1001000.

Completar com zeros resolve, desde que você saiba o que é cada coisa. A regra ingênua "até 8 dígitos é CEP" transforma o 191 do Banco do Brasil no CEP 00000191, que não existe. Nós caímos nisso numa versão anterior da nossa ferramenta.

A regra que usamos hoje para colunas misturadas olha o tamanho com mais cuidado:

  • tem letra, ou tem de 9 a 14 dígitos: CNPJ;
  • tem 7 ou 8 dígitos: CEP (um CEP que perdeu zeros fica com 7);
  • tem de 1 a 6 dígitos: CNPJ que perdeu os zeros (191 → 00.000.000/0001-91). Nenhum CEP real perde tantos zeros: o menor começa com 01.

O caso que nenhuma regra resolve sozinha: um CNPJ que ficou com 7 ou 8 dígitos (ex.: 00.001.234/0001-xx). Se a coluna é só de CNPJs, diga isso ao código em vez de deixar adivinhar. O melhor continua sendo manter CNPJ e CEP em colunas separadas.

import re

def limpa(v):
    return re.sub(r"[^0-9A-Za-z]", "", str(v)).upper()

def normaliza_cnpj(v):
    s = limpa(v)
    return s.zfill(14) if s.isdigit() else s   # alfanumérico: não completa

def normaliza_cep(v):
    return limpa(v).zfill(8)

def detecta_tipo(v):
    """Para colunas misturadas: a regra acima."""
    s = limpa(v)
    if not s.isdigit():
        return "cnpj"            # tem letra: CNPJ alfanumérico
    if 7 <= len(s) <= 8:
        return "cep"
    return "cnpj"                # 1 a 6 dígitos (zeros perdidos) ou 9 a 14
Enter fullscreen mode Exit fullscreen mode

Repare que removemos só a pontuação, não as letras: o CNPJ alfanumérico (IN RFB 2.229/2024) usa letras nas 12 primeiras posições.

Armadilha 2: validar o DV não basta

Validar o dígito verificador localmente economiza requisições. No CNPJ alfanumérico, a regra é a mesma de sempre (módulo 11, pesos de 2 a 9), e cada caractere vale o código ASCII menos 48: os dígitos continuam valendo 0 a 9 e A passa a valer 17.

def _dv(base):
    pesos = [2, 3, 4, 5, 6, 7, 8, 9]
    soma = sum((ord(c) - 48) * pesos[i % 8] for i, c in enumerate(reversed(base)))
    r = soma % 11
    return "0" if r < 2 else str(11 - r)

def cnpj_valido(cnpj):
    if not re.fullmatch(r"[0-9A-Z]{12}[0-9]{2}", cnpj) or len(set(cnpj)) == 1:
        return False
    d1 = _dv(cnpj[:12])
    return cnpj[12:] == d1 + _dv(cnpj[:12] + d1)
Enter fullscreen mode Exit fullscreen mode

A pegadinha: 00000000000000 passa na conta do DV. Por isso a checagem len(set(cnpj)) == 1 barra sequências repetidas. (O exemplo oficial de CNPJ alfanumérico, 12ABC34501DE35, passa.)

Armadilha 3: respeite as APIs públicas (e tenha um plano B)

BrasilAPI, Minha Receita, ViaCEP e OpenCEP são projetos públicos e gratuitos, mantidos pela comunidade ou por voluntários. Eles têm limites, e com razão. Em lotes grandes você vai ver 429 (limite de requisições), 5xx e timeouts.

O que aprendemos:

  • Coloque um teto próprio de chamadas por minuto, por serviço, em vez de descobrir o limite na base do 429.
  • Recebeu 429? Não insista. Martelar a mesma API com retries só piora a fila para todo mundo. Passe para a próxima fonte.
  • Retry com backoff faz sentido para 5xx e erro de rede, que costumam ser passageiros.
  • Detalhe do ViaCEP: CEP inexistente volta com status 200 e {"erro": "true"}, não com 404.
import time
import requests

sessao = requests.Session()
sessao.headers["User-Agent"] = "exemplo-artigo-cnpj-cep/1.0"
LIMITE_POR_MINUTO = 90
_chamadas = {}   # host -> horários das chamadas no último minuto

def _pode_chamar(url):
    host = url.split("/")[2]
    agora = time.monotonic()
    janela = [t for t in _chamadas.get(host, []) if agora - t < 60]
    _chamadas[host] = janela
    if len(janela) >= LIMITE_POR_MINUTO:
        return False
    janela.append(agora)
    return True

def get_json(urls, tentativas=3):
    for url in urls:
        for tentativa in range(tentativas):
            if not _pode_chamar(url):
                break                     # teto local atingido: próxima fonte
            try:
                r = sessao.get(url, timeout=15)
            except requests.RequestException:
                time.sleep(2 ** tentativa)
                continue
            if r.status_code == 200:
                dados = r.json()
                if isinstance(dados, dict) and dados.get("erro"):  # ViaCEP: 200 + {"erro": "true"}
                    break
                return dados
            if r.status_code >= 500:
                time.sleep(2 ** tentativa)  # 1, 2, 4 s
                continue
            break  # 429: não insiste nessa fonte. 400/404: idem. Vai para a próxima.
    return None
Enter fullscreen mode Exit fullscreen mode

O número 90 é o teto que usamos para a BrasilAPI; ajuste para cada serviço e leia a documentação de cada um. Se o seu lote é grande e sem pressa, a opção mais educada é simplesmente ir mais devagar.

Armadilha 4: perder o alinhamento com a planilha

Se você grava só os sucessos, o PROCV/merge com a planilha original quebra. Grave uma linha por entrada, sempre com um status:

def consulta_cnpj(valor):
    cnpj = normaliza_cnpj(valor)
    if not cnpj_valido(cnpj):
        return {"entrada": valor, "cnpj": cnpj, "status": "invalido"}
    d = get_json([f"https://brasilapi.com.br/api/cnpj/v1/{cnpj}",
                  f"https://minhareceita.org/{cnpj}"])
    if not d:
        return {"entrada": valor, "cnpj": cnpj, "status": "nao_encontrado"}
    return {"entrada": valor, "cnpj": cnpj, "status": "ok",
            "razao_social": d.get("razao_social"),
            "situacao": d.get("descricao_situacao_cadastral"),
            "uf": d.get("uf")}

def consulta_cep(valor):
    cep = normaliza_cep(valor)
    if not re.fullmatch(r"\d{8}", cep):
        return {"entrada": valor, "cep": cep, "status": "invalido"}
    d = get_json([f"https://brasilapi.com.br/api/cep/v2/{cep}",
                  f"https://viacep.com.br/ws/{cep}/json/"])
    if not d:
        return {"entrada": valor, "cep": cep, "status": "nao_encontrado"}
    return {"entrada": valor, "cep": cep, "status": "ok",
            "logradouro": d.get("street") or d.get("logradouro"),
            "cidade": d.get("city") or d.get("localidade"),
            "uf": d.get("state") or d.get("uf")}

def consulta(valor):
    return consulta_cnpj(valor) if detecta_tipo(valor) == "cnpj" else consulta_cep(valor)

for v in ["33.000.167/0001-01", "191", "11.111.111/1111-11", "1001000", "01310-100"]:
    print(consulta(v))
Enter fullscreen mode Exit fullscreen mode

Saída quando rodamos (coluna misturada, do jeito que vem do Excel):

{'entrada': '33.000.167/0001-01', 'cnpj': '33000167000101', 'status': 'ok', 'razao_social': 'PETROLEO BRASILEIRO S A PETROBRAS', 'situacao': 'ATIVA', 'uf': 'RJ'}
{'entrada': '191', 'cnpj': '00000000000191', 'status': 'ok', 'razao_social': 'BANCO DO BRASIL SA', 'situacao': 'ATIVA', 'uf': 'DF'}
{'entrada': '11.111.111/1111-11', 'cnpj': '11111111111111', 'status': 'invalido'}
{'entrada': '1001000', 'cep': '01001000', 'status': 'ok', 'logradouro': 'Praça da Sé', 'cidade': 'São Paulo', 'uf': 'SP'}
{'entrada': '01310-100', 'cep': '01310100', 'status': 'ok', 'logradouro': 'Avenida Paulista', 'cidade': 'São Paulo', 'uf': 'SP'}
Enter fullscreen mode Exit fullscreen mode

Bônus de LGPD: a resposta de CNPJ traz o QSA (nomes de sócios), e no MEI a razão social costuma incluir o CPF do titular. Se você só quer saber se a empresa existe e está ativa, não guarde o resto.

Um agradecimento sincero aos mantenedores da BrasilAPI, do Minha Receita, do ViaCEP e do OpenCEP: nada disso existiria sem esses serviços gratuitos.

Se você não quiser manter isso (a parte paga)

Empacotamos esse fluxo num Actor na Apify, que é pago: CNPJ & CEP Bulk Lookup Brazil — Consulta CNPJ e CEP em Lote, hoje na v0.2.8 (https://apify.com/brasil_utils/brazil-cnpj-cep-bulk-lookup). Como funciona:

  • Colar a coluna da planilha: no campo bulkText ele aplica a regra da armadilha 1 (letras ou 9 a 14 dígitos = CNPJ; 7 ou 8 = CEP; 1 a 6 = CNPJ com zeros restaurados, então 191 vira 00.000.000/0001-91). Se a coluna tem um tipo só, dá para forçar CNPJ ou CEP. Também há os campos separados cnpjs e ceps.
  • Fontes: CNPJ na BrasilAPI e, se falhar, Minha Receita. CEP na BrasilAPI, depois ViaCEP, depois OpenCEP.
  • Limite: no máximo 90 chamadas por minuto à BrasilAPI por execução. Acima disso, ou se a BrasilAPI responder 429 ou 403, ele passa na hora para a fonte seguinte, sem retentar, e pausa a BrasilAPI por 60 s naquela execução. Erros passageiros (timeout, rede, 5xx) em qualquer fonte são retentados com backoff exponencial, respeitando o Retry-After, antes de ir para a próxima. Se todas as fontes falharem, a linha sai como erro e não é cobrada.
  • Saída: uma linha por entrada, com status, deduplicação e sem dados pessoais de sócios.
  • Preço: US$0,002 por consulta encontrada, cobrado por evento. Não há cobrança extra de compute. Inválidos, duplicados e não encontrados são grátis, e uma execução sem entrada não custa nada. Se a execução reinicia no meio, ela retoma de onde parou sem cobrar de novo o que já foi cobrado.

Exemplo via API, colando a coluna misturada:

curl -X POST "https://api.apify.com/v2/acts/brasil_utils~brazil-cnpj-cep-bulk-lookup/run-sync-get-dataset-items" \
  -H "Authorization: Bearer $APIFY_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"bulkText": "33.000.167/0001-01\n191\n1001000", "bulkTextType": "auto"}'
Enter fullscreen mode Exit fullscreen mode

Tem também um exemplo pronto para rodar: https://apify.com/brasil_utils/brazil-cnpj-cep-bulk-lookup/examples/consulta-cnpj-e-cep-em-lote-exemplo. E um template n8n (lista no Google Sheets → consulta → planilha enriquecida) está disponível sob pedido: é só comentar aqui.

Sendo sinceros: para poucas centenas de consultas por mês, o script acima resolve. O Actor compensa quando o volume cresce ou quando a consulta roda dentro do Make, Zapier ou n8n.

E vocês, já adaptaram os sistemas para o CNPJ alfanumérico? Contem nos comentários o que quebrou.

Top comments (0)