Validar o CSV
Um download que termina com HTTP 200 ainda pode estar incompleto: a conexão cai, um proxy corta o corpo, um disco enche. O CSV da análise traz o necessário para provar que chegou inteiro. O validador abaixo descarta o arquivo ao primeiro sinal de problema.
import { createHash } from 'node:crypto'
const COLUNAS = ['registro', 'analise_id', 'executada_em', 'snapshot_id', 'versao_avaliador', 'regulamento_degradado', 'produtos_avaliados', 'total_achados', 'produto_id', 'codigo_produto', 'ncm', 'tipo', 'codigo_atributo', 'detalhe']
/** Lê células sempre entre aspas, separadas por `;`, com `""` como aspas literais. */function linhas(texto) { const saida = [] let linha = [], celula = '', aspas = false for (let i = 0; i < texto.length; i++) { const c = texto[i] if (aspas) { if (c === '"' && texto[i + 1] === '"') { celula += '"'; i++ } else if (c === '"') aspas = false else celula += c } else if (c === '"') aspas = true else if (c === ';') { linha.push(celula); celula = '' } else if (c === '\r' && texto[i + 1] === '\n') { linha.push(celula); saida.push(linha); linha = []; celula = ''; i++ } else throw new Error(`Caractere inesperado fora de aspas na posição ${i}`) } if (aspas || linha.length || celula) throw new Error('Arquivo terminou no meio de uma linha') return saida}
/** Aceita o CSV só depois de conferir bytes, digest, cabeçalho, contagem e conclusão. */export function validarCsv(bytes, cabecalhos) { const tamanho = Number(cabecalhos.get('x-cotejar-tamanho-bytes')) if (bytes.byteLength !== tamanho) throw new Error(`Tamanho ${bytes.byteLength}, esperado ${tamanho}`) const digest = `sha-256=:${createHash('sha256').update(bytes).digest('base64')}:` if (digest !== cabecalhos.get('content-digest')) throw new Error('Content-Digest não confere')
const texto = new TextDecoder('utf-8', { fatal: true, ignoreBOM: true }).decode(bytes) if (!texto.startsWith('')) throw new Error('BOM ausente') const [cabecalho, ...registros] = linhas(texto.slice(1)) if (cabecalho?.join(';') !== COLUNAS.join(';')) throw new Error('Cabeçalho inesperado')
const objeto = valores => Object.fromEntries(COLUNAS.map((c, i) => [c, valores[i]])) const resumo = objeto(registros.at(0) ?? []) const conclusao = objeto(registros.at(-1) ?? []) const achados = registros.slice(1, -1).map(objeto) if (resumo.registro !== 'resumo' || conclusao.registro !== 'conclusao') throw new Error('Resumo ou conclusão ausente') if (achados.some(a => a.registro !== 'achado')) throw new Error('Registro fora de ordem') if (achados.length !== Number(resumo.total_achados)) throw new Error('Quantidade de achados não confere') return { resumo, achados }}import { validarCsv } from './validar-csv.mjs'
const resposta = await fetch(`${base}/v1/analises/${id}/resultados.csv`, { headers: { authorization: `Bearer ${chave}` },})if (!resposta.ok) throw new Error(`HTTP ${resposta.status}`)const bytes = new Uint8Array(await resposta.arrayBuffer())const { resumo, achados } = validarCsv(bytes, resposta.headers)// Só agora grave o arquivo ou importe os achados.import base64import csvimport hashlibimport io
COLUNAS = ["registro", "analise_id", "executada_em", "snapshot_id", "versao_avaliador", "regulamento_degradado", "produtos_avaliados", "total_achados", "produto_id", "codigo_produto", "ncm", "tipo", "codigo_atributo", "detalhe"]
def validar_csv(corpo: bytes, cabecalhos) -> tuple[dict, list[dict]]: """Aceita o CSV só depois de conferir bytes, digest, cabeçalho, contagem e conclusão.""" if len(corpo) != int(cabecalhos["X-Cotejar-Tamanho-Bytes"]): raise ValueError("Tamanho não confere") digest = "sha-256=:" + base64.b64encode(hashlib.sha256(corpo).digest()).decode() + ":" if digest != cabecalhos["Content-Digest"]: raise ValueError("Content-Digest não confere")
texto = corpo.decode("utf-8") # erro de decodificação também descarta o arquivo if not texto.startswith(""): raise ValueError("BOM ausente") leitor = csv.reader(io.StringIO(texto[1:], newline=""), delimiter=";", quotechar='"', strict=True) cabecalho, *registros = list(leitor) if cabecalho != COLUNAS or len(registros) < 2: raise ValueError("Cabeçalho ou registros inesperados")
resumo, *achados, conclusao = (dict(zip(COLUNAS, r)) for r in registros) if resumo["registro"] != "resumo" or conclusao["registro"] != "conclusao": raise ValueError("Resumo ou conclusão ausente") if any(a["registro"] != "achado" for a in achados): raise ValueError("Registro fora de ordem") if len(achados) != int(resumo["total_achados"]): raise ValueError("Quantidade de achados não confere") return resumo, achadosimport requestsfrom validar_csv import validar_csv
resposta = requests.get(f"{base}/v1/analises/{id}/resultados.csv", headers={"Authorization": f"Bearer {chave}"}, timeout=120)resposta.raise_for_status()resumo, achados = validar_csv(resposta.content, resposta.headers)# Só agora grave o arquivo ou importe os achados.O formato
Seção intitulada “O formato”UTF-8 com BOM, separador ;, toda célula entre aspas, linhas terminadas em CRLF. A primeira linha é o cabeçalho; depois vêm um registro resumo, todos os registros achado e um registro final conclusao, inclusive quando não há achados.
"registro";"analise_id";"executada_em";"snapshot_id";"versao_avaliador";"regulamento_degradado";"produtos_avaliados";"total_achados";"produto_id";"codigo_produto";"ncm";"tipo";"codigo_atributo";"detalhe""resumo";"0198d000-0000-7000-8000-000000000001";"2026-10-13T16:40:12.000Z";"snap-2026-10-13T12";"2";"false";"3";"2";"";"";"";"";"";"""achado";"0198d000-0000-7000-8000-000000000001";"2026-10-13T16:40:12.000Z";"snap-2026-10-13T12";"2";"false";"3";"2";"0198d000-0000-7000-8000-0000000000a1";"000123";"40151100";"orfao";"";"NCM 40151100 não consta na tabela vigente do Portal Único."| Coluna | Conteúdo |
|---|---|
registro |
resumo, achado ou conclusao. |
analise_id … total_achados |
Contexto da execução, repetido em toda linha. total_achados é a soma das divergências. |
produto_id |
UUID Cotejar do produto (só em achado). |
codigo_produto |
Código preservado na execução. Pode começar com ' de proteção. |
ncm |
NCM com oito dígitos, como texto. |
tipo |
orfao, bloqueante, deriva ou anvisa. |
codigo_atributo |
Atributo envolvido, quando houver. |
detalhe |
Descrição do achado. |
As cinco conferências
Seção intitulada “As cinco conferências”| Conferência | Contra o quê |
|---|---|
| Tamanho em bytes | X-Cotejar-Tamanho-Bytes, contando BOM e conclusão. |
| SHA-256 | Content-Digest: sha-256=:<base64>:, calculado sobre os bytes recebidos, antes de decodificar. |
| Cabeçalho | As 14 colunas, nesta ordem. |
| Contagem | Número de linhas achado igual a total_achados do resumo. |
| Conclusão | Última linha é conclusao. |
Se qualquer conferência falhar, descarte tudo e baixe de novo. Não existe retomada por Range. O download lê o resultado guardado: não executa outra análise nem consome franquia.
Para testar sem rodar uma análise, use o CSV de exemplo com X-Cotejar-Tamanho-Bytes: 982 e Content-Digest: sha-256=:Ou+bAOwk3pO1PDkLEAYp4zBdEkqqgMAl/hq3npIjijY=:. Os dois validadores também foram conferidos contra um CSV real de 3,5 MB com 11.702 achados.