Pular para o conteúdo

Validar o CSV

GET/v1/analises/{id}/resultados.csvcatalogo:lerReferência

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.

validar-csv.mjs
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.

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.

analise-exemplo.csv (início)
"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.
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.