Trabalhando com arquivos CSV

[105] Trabalhando com arquivos CSV

CSV parece o formato mais simples que existe até o primeiro campo com vírgula dentro. O artigo constrói o parser do zero — o ingênuo, depois o que entende aspas —, converte tipos, gera e baixa arquivo no navegador, detecta o delimitador do Excel brasileiro e explica quando parar e usar o PapaParse.
Javascript

26 min de leitura

JSON domina as APIs modernas, mas o CSV domina o mundo real dos dados. Planilhas financeiras, exportações de bancos de dados, relatórios de e-commerce, dados de sensores, listas de contatos — tudo isso vive em CSV. Todo desenvolvedor, cedo ou tarde, precisa ler, processar e gerar arquivos .csv.

Neste artigo vamos aprender a trabalhar com CSV do zero, sem bibliotecas externas, e depois ver quando e como usar uma biblioteca para casos complexos.

O que é CSV?

CSVComma-Separated Values — é um formato de texto simples onde cada linha representa um registro e os campos são separados por vírgulas (ou outro delimitador):

nome,email,idade,cidade
Ana Paula,ana@email.com,28,São Paulo
Carlos Silva,carlos@email.com,32,Curitiba
Beatriz Costa,bia@email.com,25,Rio de Janeiro

Simples assim. A primeira linha normalmente é o cabeçalho com os nomes das colunas.

Lendo CSV manualmente — o básico

const csvTexto = `nome,email,idade,cidade
Ana Paula,ana@email.com,28,São Paulo
Carlos Silva,carlos@email.com,32,Curitiba
Beatriz Costa,bia@email.com,25,Rio de Janeiro`;

function parsearCSV(texto) {
  const linhas = texto.trim().split("\n");
  const cabecalho = linhas[0].split(",");

  return linhas.slice(1).map(linha => {
    const valores = linha.split(",");
    return cabecalho.reduce((obj, col, i) => {
      obj[col.trim()] = valores[i]?.trim();
      return obj;
    }, {});
  });
}

const dados = parsearCSV(csvTexto);
console.log(dados);
/*
[
  { nome: "Ana Paula", email: "ana@email.com", idade: "28", cidade: "São Paulo" },
  { nome: "Carlos Silva", email: "carlos@email.com", idade: "32", cidade: "Curitiba" },
  { nome: "Beatriz Costa", email: "bia@email.com", idade: "25", cidade: "Rio de Janeiro" }
]
*/

Funciona para casos simples. Mas o mundo real é mais complicado.

O problema das vírgulas dentro dos campos

O CSV tem uma armadilha clássica: e se o campo contiver uma vírgula? A solução padrão é envolver o campo em aspas duplas:

nome,descricao,preco
Notebook Pro,"Processador i7, 16GB RAM, SSD 512GB",3500.00
Mouse Gamer,"Clique preciso, 6 botões",189.90

E se o campo contiver aspas? Elas são escapadas dobrando: "":

produto,observacao
Caneta,"Cor ""azul"" especial"

Isso torna o parse manual muito mais complexo:

function parsearCSVRobusto(texto, delimitador = ",") {
  const linhas = texto.trim().split("\n");
  const cabecalho = parsearLinha(linhas[0], delimitador);

  return linhas.slice(1).map(linha => {
    const valores = parsearLinha(linha, delimitador);
    return cabecalho.reduce((obj, col, i) => {
      obj[col.trim()] = valores[i] ?? "";
      return obj;
    }, {});
  });
}

function parsearLinha(linha, delimitador = ",") {
  const resultado = [];
  let campo = "";
  let dentroDeAspas = false;

  for (let i = 0; i < linha.length; i++) {
    const char = linha[i];
    const proximo = linha[i + 1];

    if (char === '"' && !dentroDeAspas) {
      dentroDeAspas = true;
      continue;
    }

    if (char === '"' && dentroDeAspas) {
      if (proximo === '"') {
        // Aspas escapadas "" → uma aspas literal
        campo += '"';
        i++; // pula o próximo "
      } else {
        dentroDeAspas = false;
      }
      continue;
    }

    if (char === delimitador && !dentroDeAspas) {
      resultado.push(campo.trim());
      campo = "";
      continue;
    }

    campo += char;
  }

  resultado.push(campo.trim()); // último campo
  return resultado;
}

// Testando
const csv = `produto,descricao,preco
Notebook,"Processador i7, 16GB RAM",3500.00
Caneta,"Cor ""azul"" especial",2.50
Mouse Gamer,"6 botões, RGB",189.90`;

const dados = parsearCSVRobusto(csv);
console.log(dados[0].descricao); // "Processador i7, 16GB RAM"
console.log(dados[1].descricao); // 'Cor "azul" especial'

Convertendo tipos automaticamente

O CSV é sempre texto puro. Você precisa converter tipos manualmente:

function converterTipos(obj) {
  const resultado = {};

  for (const [chave, valor] of Object.entries(obj)) {
    // Vazio → null
    if (valor === "" || valor === "null" || valor === "NULL") {
      resultado[chave] = null;
      continue;
    }

    // Boolean
    if (valor === "true" || valor === "TRUE") { resultado[chave] = true; continue; }
    if (valor === "false" || valor === "FALSE") { resultado[chave] = false; continue; }

    // Número
    const numero = Number(valor);
    if (!isNaN(numero) && valor !== "") {
      resultado[chave] = numero;
      continue;
    }

    // Data ISO. Cuidado: "2022-03-15", sem hora, é lida como UTC e
    // exibida como 14/03 no Brasil. Para uma data de calendário, o
    // seguro é fixar o meio-dia local: new Date(valor + "T12:00:00").
    const data = new Date(valor);
    if (!isNaN(data.getTime()) && valor.includes("-")) {
      resultado[chave] = data;
      continue;
    }

    // String simples
    resultado[chave] = valor;
  }

  return resultado;
}

// Uso junto com o parser
function parsearCSVComTipos(texto, delimitador = ",") {
  return parsearCSVRobusto(texto, delimitador).map(converterTipos);
}

const csv = `nome,idade,ativo,salario,admissao
Ana,28,true,5000.50,2022-03-15
Bruno,35,false,7200,2019-08-01`;

const dados = parsearCSVComTipos(csv);
console.log(dados[0].idade + 1);          // 29 — número
console.log(dados[0].ativo === true);     // true — boolean
console.log(dados[0].salario + 500);      // 5500.5 — número
console.log(dados[0].admissao.getFullYear()); // 2022 — Date

Gerando CSV a partir de dados

function gerarCSV(dados, opcoes = {}) {
  const {
    delimitador = ",",
    incluirCabecalho = true,
    campos = null, // null = todos os campos
  } = opcoes;

  if (dados.length === 0) return "";

  const colunas = campos || Object.keys(dados[0]);

  function escaparCampo(valor) {
    if (valor === null || valor === undefined) return "";

    const texto = valor instanceof Date
      ? valor.toISOString()
      : String(valor);

    // Envolve em aspas se contém delimitador, aspas ou quebra de linha
    if (
      texto.includes(delimitador) ||
      texto.includes('"') ||
      texto.includes("\n")
    ) {
      return `"${texto.replace(/"/g, '""')}"`;
    }

    return texto;
  }

  const linhas = [];

  if (incluirCabecalho) {
    linhas.push(colunas.map(escaparCampo).join(delimitador));
  }

  dados.forEach(item => {
    const linha = colunas.map(col => escaparCampo(item[col]));
    linhas.push(linha.join(delimitador));
  });

  return linhas.join("\n");
}

// Testando
const funcionarios = [
  { nome: "Ana", cargo: "Dev", salario: 5000, ativo: true },
  { nome: "Bruno, Jr.", cargo: "Designer", salario: 4000, ativo: false },
  { nome: 'Clara "CB"', cargo: "QA", salario: 4500, ativo: true },
];

const csv = gerarCSV(funcionarios);
console.log(csv);
/*
nome,cargo,salario,ativo
Ana,Dev,5000,true
"Bruno, Jr.",Designer,4000,false
"Clara ""CB""",QA,4500,true
*/

Exportando CSV para download no navegador

function baixarCSV(dados, nomeArquivo = "exportacao.csv", opcoes = {}) {
  const csvTexto = gerarCSV(dados, opcoes);

  // BOM para compatibilidade com Excel no Windows (UTF-8)
  const bom = "\uFEFF";
  const blob = new Blob([bom + csvTexto], {
    type: "text/csv;charset=utf-8;",
  });

  const url = URL.createObjectURL(blob);
  const link = document.createElement("a");
  link.href = url;
  link.download = nomeArquivo;
  link.style.display = "none";

  document.body.appendChild(link);
  link.click();
  document.body.removeChild(link);

  // Libera a URL da memória
  setTimeout(() => URL.revokeObjectURL(url), 100);
}

// Uso
const vendas = [
  { mes: "Janeiro", receita: 48000, pedidos: 320, ticket: 150 },
  { mes: "Fevereiro", receita: 52000, pedidos: 347, ticket: 149.9 },
  { mes: "Março", receita: 61000, pedidos: 410, ticket: 148.8 },
];

document.querySelector("#btn-exportar").addEventListener("click", () => {
  baixarCSV(vendas, "relatorio-vendas-2025.csv");
});

Lendo CSV enviado pelo usuário

function lerArquivoCSV(arquivo) {
  return new Promise((resolve, reject) => {
    const reader = new FileReader();

    reader.onload = (evento) => {
      const texto = evento.target.result;
      try {
        const dados = parsearCSVComTipos(texto);
        resolve(dados);
      } catch (erro) {
        reject(new Error(`Erro ao parsear CSV: ${erro.message}`));
      }
    };

    reader.onerror = () => reject(new Error("Erro ao ler o arquivo."));
    reader.readAsText(arquivo, "UTF-8");
  });
}

// No HTML: <input type="file" id="arquivo-csv" accept=".csv">
const inputArquivo = document.querySelector("#arquivo-csv");

inputArquivo.addEventListener("change", async (e) => {
  const arquivo = e.target.files[0];
  if (!arquivo) return;

  if (!arquivo.name.endsWith(".csv")) {
    console.error("Por favor, selecione um arquivo .csv");
    return;
  }

  try {
    const dados = await lerArquivoCSV(arquivo);
    console.log(`${dados.length} registros carregados.`);
    console.log("Primeiro registro:", dados[0]);
    processarDados(dados);
  } catch (erro) {
    console.error(erro.message);
  }
});

Buscando CSV de uma URL com Fetch

async function buscarCSV(url, opcoes = {}) {
  const response = await fetch(url);

  if (!response.ok) {
    throw new Error(`Erro ${response.status} ao buscar CSV.`);
  }

  const texto = await response.text();
  return parsearCSVComTipos(texto, opcoes.delimitador);
}

// Exemplo com dados públicos do IBGE (ou qualquer CSV público)
async function carregarDadosPublicos() {
  try {
    const dados = await buscarCSV(
      "https://raw.githubusercontent.com/datasets/population/main/data/population.csv"
    );

    console.log(`${dados.length} registros carregados.`);

    // Filtra só o Brasil
    const brasil = dados.filter(d =>
      d["Country Name"] === "Brazil" || d["Country Code"] === "BRA"
    );

    console.log("Dados do Brasil:", brasil);
  } catch (erro) {
    console.error("Erro:", erro.message);
  }
}

Delimitadores alternativos — TSV e ponto e vírgula

Nem todo "CSV" usa vírgula. O Excel brasileiro gera com ponto e vírgula por padrão (pois a vírgula é separador decimal no Brasil). Arquivos TSV usam tab:

// Detectar delimitador automaticamente
function detectarDelimitador(primeiraLinha) {
  const delimitadores = [",", ";", "\t", "|"];

  // Contar com split, e não com RegExp: o "|" é metacaractere de expressão
  // regular e casaria com a string vazia em toda posição, vencendo sempre.
  // Para usar regex aqui seria preciso escapar o delimitador antes.
  const contagens = delimitadores.map(d => ({
    delimitador: d,
    contagem: primeiraLinha.split(d).length - 1,
  }));

  return contagens.sort((a, b) => b.contagem - a.contagem)[0].delimitador;
}

function parsearCSVAuto(texto) {
  const primeiraLinha = texto.split("\n")[0];
  const delimitador = detectarDelimitador(primeiraLinha);
  console.log(`Delimitador detectado: "${delimitador === "\t" ? "TAB" : delimitador}"`);
  return parsearCSVRobusto(texto, delimitador);
}

// Funciona com qualquer um:
parsearCSVAuto("nome;email;idade
Ana;ana@email.com;28");    // ponto e vírgula
parsearCSVAuto("nome	email	idade
Ana	ana@email.com	28"); // tab
parsearCSVAuto("nome,email,idade
Ana,ana@email.com,28");     // vírgula

Quando usar uma biblioteca — PapaParse

Para casos complexos em produção, a biblioteca PapaParse é o padrão da indústria:

<script src="https://cdnjs.cloudflare.com/ajax/libs/PapaParse/5.4.1/papaparse.min.js"></script>
// PapaParse — simples, robusto, rápido

// Parse de string
const resultado = Papa.parse(csvTexto, {
  header: true,           // usa primeira linha como chaves
  dynamicTyping: true,    // converte tipos automaticamente
  skipEmptyLines: true,   // ignora linhas vazias
  delimiter: "",          // auto-detecta
});

console.log(resultado.data);   // array de objetos
console.log(resultado.errors); // erros encontrados
console.log(resultado.meta);   // delimitador usado, encoding, etc.

// Parse de arquivo (streaming — bom para arquivos grandes)
Papa.parse(arquivo, {
  header: true,
  dynamicTyping: true,
  step: (resultado) => {
    // Processa linha por linha — não carrega tudo na memória
    processarLinha(resultado.data);
  },
  complete: (resultado) => {
    console.log(`${resultado.data.length} linhas processadas.`);
  },
  error: (erro) => {
    console.error("Erro:", erro.message);
  },
});

// Parse de URL remota
Papa.parse("https://dados.exemplo.com/relatorio.csv", {
  download: true,
  header: true,
  dynamicTyping: true,
  complete: (resultado) => {
    console.log(resultado.data);
  },
});

// Gerar CSV
const csv = Papa.unparse(dados, {
  quotes: true,    // sempre envolve campos em aspas
  delimiter: ";",  // ponto e vírgula para Excel BR
  newline: "\r\n", // Windows line ending
});

JSON vs CSV — quando usar cada um

Critério JSON CSV
Estrutura aninhada ✅ Suporta objetos/arrays ❌ Apenas tabelas planas
Tipos de dado ✅ Preserva string, number, boolean, null ⚠️ Tudo é string
Legibilidade ✅ Clara e auto-descritiva ✅ Simples para tabelas
Tamanho ⚠️ Maior (chaves repetidas) ✅ Compacto
Excel / Planilhas ❌ Não abre diretamente ✅ Abre com duplo clique
APIs REST ✅ Padrão da indústria ⚠️ Raro
Exportação de relatórios ⚠️ Necessita processamento ✅ Ideal
Big Data / Analytics ⚠️ Pesado para volumes grandes ✅ Eficiente
Configuração ✅ Ideal ❌ Não faz sentido

Exemplo completo — importador de contatos

<!DOCTYPE html>
<html lang="pt-BR">
<head>
  <meta charset="UTF-8">
  <title>Importador de Contatos CSV</title>
  <style>
    * { box-sizing: border-box; margin: 0; padding: 0; }
    body {
      font-family: 'Segoe UI', sans-serif;
      background: #f0f2f5;
      padding: 2rem 1rem;
    }
    .app {
      max-width: 700px;
      margin: 0 auto;
      background: white;
      border-radius: 12px;
      padding: 2rem;
      box-shadow: 0 4px 24px rgba(0,0,0,.08);
    }
    h1 { margin-bottom: 1.5rem; font-size: 1.4rem; }
    .zona-drop {
      border: 2px dashed #d0d5dd;
      border-radius: 10px;
      padding: 2.5rem;
      text-align: center;
      cursor: pointer;
      transition: all .2s;
      margin-bottom: 1.5rem;
      color: #667085;
    }
    .zona-drop:hover, .zona-drop.arrastando {
      border-color: #5c6bc0;
      background: #f5f4ff;
      color: #5c6bc0;
    }
    .zona-drop input { display: none; }
    .zona-drop .icone { font-size: 2.5rem; margin-bottom: .5rem; }
    .acoes {
      display: flex;
      gap: .75rem;
      margin-bottom: 1.5rem;
      flex-wrap: wrap;
    }
    button {
      padding: .6rem 1.25rem;
      border: none;
      border-radius: 8px;
      cursor: pointer;
      font-size: .9rem;
      font-weight: 600;
      transition: all .2s;
    }
    .btn-primary { background: #5c6bc0; color: white; }
    .btn-primary:hover { background: #3949ab; }
    .btn-secondary { background: #f2f4f7; color: #344054; }
    .btn-secondary:hover { background: #e4e7ec; }
    .btn-danger { background: #fef3f2; color: #b42318; }
    .stats {
      display: flex;
      gap: 1rem;
      margin-bottom: 1.5rem;
      flex-wrap: wrap;
    }
    .stat {
      background: #f9fafb;
      border: 1px solid #eaecf0;
      border-radius: 8px;
      padding: .75rem 1.25rem;
      font-size: .85rem;
    }
    .stat strong { display: block; font-size: 1.4rem; color: #5c6bc0; }
    table {
      width: 100%;
      border-collapse: collapse;
      font-size: .9rem;
    }
    th {
      background: #f9fafb;
      padding: .65rem 1rem;
      text-align: left;
      font-size: .8rem;
      color: #667085;
      text-transform: uppercase;
      letter-spacing: .05em;
      border-bottom: 1px solid #eaecf0;
    }
    td {
      padding: .65rem 1rem;
      border-bottom: 1px solid #f2f4f7;
      color: #344054;
    }
    tr:last-child td { border: none; }
    tr:hover td { background: #f9fafb; }
    .badge {
      display: inline-block;
      padding: .15rem .6rem;
      border-radius: 999px;
      font-size: .75rem;
      font-weight: 600;
    }
    .badge-sim { background: #ecfdf3; color: #027a48; }
    .badge-nao { background: #fef3f2; color: #b42318; }
    .vazio { text-align: center; color: #98a2b3; padding: 2rem; }
    .erro { background: #fef3f2; border: 1px solid #fecdca;
            border-radius: 8px; padding: 1rem; color: #b42318;
            margin-bottom: 1rem; display: none; }
    .erro.visivel { display: block; }
  </style>
</head>
<body>
<div class="app">
  <h1>📋 Importador de Contatos</h1>

  <div class="zona-drop" id="zona-drop">
    <div class="icone">📂</div>
    <p><strong>Arraste um arquivo CSV aqui</strong></p>
    <p style="font-size:.85rem; margin-top:.4rem">ou clique para selecionar</p>
    <input type="file" id="input-arquivo" accept=".csv,.tsv,.txt">
  </div>

  <div class="erro" id="erro"></div>

  <div class="acoes">
    <button class="btn-primary" id="btn-exportar" disabled>⬇ Exportar CSV</button>
    <button class="btn-secondary" id="btn-exemplo">📄 Carregar exemplo</button>
    <button class="btn-danger" id="btn-limpar" disabled>🗑 Limpar</button>
  </div>

  <div class="stats" id="stats" style="display:none"></div>
  <div id="tabela-container">
    <p class="vazio">Nenhum dado carregado. Importe um CSV para começar.</p>
  </div>
</div>

<script>
  // ── Dados de exemplo ──────────────────────────────
  const csvExemplo = `nome,email,telefone,cidade,ativo
Ana Paula,ana@email.com,(11) 99999-0001,São Paulo,true
Carlos Silva,carlos@email.com,(41) 99999-0002,Curitiba,false
Beatriz Costa,bia@email.com,(21) 99999-0003,Rio de Janeiro,true
Diego Mendes,diego@email.com,(51) 99999-0004,Porto Alegre,true
Elena Souza,elena@email.com,(31) 99999-0005,Belo Horizonte,false`;

  // ── Estado ────────────────────────────────────────
  let contatosCarregados = [];

  // ── Referências ───────────────────────────────────
  const zonaDropEl = document.querySelector("#zona-drop");
  const inputArquivo = document.querySelector("#input-arquivo");
  const erroEl = document.querySelector("#erro");
  const statsEl = document.querySelector("#stats");
  const tabelaContainer = document.querySelector("#tabela-container");
  const btnExportar = document.querySelector("#btn-exportar");
  const btnLimpar = document.querySelector("#btn-limpar");

  // ── Parser CSV ────────────────────────────────────
  function parsearLinha(linha, delimitador) {
    const resultado = [];
    let campo = "";
    let dentroDeAspas = false;

    for (let i = 0; i < linha.length; i++) {
      const char = linha[i];
      if (char === '"' && !dentroDeAspas) { dentroDeAspas = true; continue; }
      if (char === '"' && dentroDeAspas) {
        if (linha[i + 1] === '"') { campo += '"'; i++; }
        else { dentroDeAspas = false; }
        continue;
      }
      if (char === delimitador && !dentroDeAspas) {
        resultado.push(campo.trim());
        campo = "";
        continue;
      }
      campo += char;
    }

    resultado.push(campo.trim());
    return resultado;
  }

  function detectarDelimitador(linha) {
    const opts = [",", ";", "\t", "|"];
    return opts.sort((a, b) =>
      (linha.split(b).length - 1) - (linha.split(a).length - 1)
    )[0];
  }

  function parsearCSV(texto) {
    const linhas = texto.trim().split(/\r?\n/).filter(l => l.trim());
    if (linhas.length < 2) throw new Error("CSV precisa ter pelo menos cabeçalho e uma linha.");

    const delimitador = detectarDelimitador(linhas[0]);
    const cabecalho = parsearLinha(linhas[0], delimitador);

    return linhas.slice(1).map((linha, i) => {
      const valores = parsearLinha(linha, delimitador);
      if (valores.length !== cabecalho.length) {
        throw new Error(`Linha ${i + 2} tem ${valores.length} campos (esperado: ${cabecalho.length}).`);
      }
      return cabecalho.reduce((obj, col, j) => {
        const val = valores[j];
        obj[col] = val === "true" ? true : val === "false" ? false : val;
        return obj;
      }, {});
    });
  }

  function gerarCSV(dados) {
    if (!dados.length) return "";
    const cols = Object.keys(dados[0]);

    function escapar(v) {
      const s = String(v ?? "");
      return s.includes(",") || s.includes('"') ? `"${s.replace(/"/g, '""')}"` : s;
    }

    return [
      cols.join(","),
      ...dados.map(row => cols.map(c => escapar(row[c])).join(","))
    ].join("\n");
  }

  // ── Renderização ──────────────────────────────────
  function renderizarTabela(dados) {
    if (!dados.length) {
      tabelaContainer.innerHTML = '<p class="vazio">Nenhum registro encontrado.</p>';
      statsEl.style.display = "none";
      return;
    }

    const colunas = Object.keys(dados[0]);
    const ativos = dados.filter(d => d.ativo === true).length;

    statsEl.style.display = "flex";
    statsEl.innerHTML = `
      <div class="stat"><strong>${dados.length}</strong> Contatos</div>
      <div class="stat"><strong>${ativos}</strong> Ativos</div>
      <div class="stat"><strong>${dados.length - ativos}</strong> Inativos</div>
      <div class="stat"><strong>${colunas.length}</strong> Colunas</div>
    `;

    const thead = `<thead><tr>${colunas.map(c =>
      `<th>${c.charAt(0).toUpperCase() + c.slice(1)}</th>`
    ).join("")}</tr></thead>`;

    const tbody = `<tbody>${dados.map(row =>
      `<tr>${colunas.map(col => {
        const val = row[col];
        if (typeof val === "boolean") {
          return `<td><span class="badge ${val ? "badge-sim" : "badge-nao"}">${val ? "Sim" : "Não"}</span></td>`;
        }
        return `<td>${val ?? ""}</td>`;
      }).join("")}</tr>`
    ).join("")}</tbody>`;

    tabelaContainer.innerHTML = `<table>${thead}${tbody}</table>`;
  }

  function mostrarErro(msg) {
    erroEl.textContent = `⚠️ ${msg}`;
    erroEl.classList.add("visivel");
    setTimeout(() => erroEl.classList.remove("visivel"), 5000);
  }

  function carregarDados(texto, origem = "arquivo") {
    try {
      erroEl.classList.remove("visivel");
      contatosCarregados = parsearCSV(texto);
      renderizarTabela(contatosCarregados);
      btnExportar.disabled = false;
      btnLimpar.disabled = false;
      console.log(`✅ ${contatosCarregados.length} contatos carregados de ${origem}.`);
    } catch (erro) {
      mostrarErro(erro.message);
    }
  }

  // ── Eventos ───────────────────────────────────────
  zonaDropEl.addEventListener("click", () => inputArquivo.click());

  inputArquivo.addEventListener("change", (e) => {
    const arquivo = e.target.files[0];
    if (!arquivo) return;
    const reader = new FileReader();
    reader.onload = (ev) => carregarDados(ev.target.result, arquivo.name);
    reader.readAsText(arquivo, "UTF-8");
  });

  zonaDropEl.addEventListener("dragover", (e) => {
    e.preventDefault();
    zonaDropEl.classList.add("arrastando");
  });

  zonaDropEl.addEventListener("dragleave", () => {
    zonaDropEl.classList.remove("arrastando");
  });

  zonaDropEl.addEventListener("drop", (e) => {
    e.preventDefault();
    zonaDropEl.classList.remove("arrastando");
    const arquivo = e.dataTransfer.files[0];
    if (!arquivo) return;
    const reader = new FileReader();
    reader.onload = (ev) => carregarDados(ev.target.result, arquivo.name);
    reader.readAsText(arquivo, "UTF-8");
  });

  document.querySelector("#btn-exemplo").addEventListener("click", () => {
    carregarDados(csvExemplo, "exemplo");
  });

  btnExportar.addEventListener("click", () => {
    if (!contatosCarregados.length) return;
    const bom = "\uFEFF";
    const blob = new Blob([bom + gerarCSV(contatosCarregados)], {
      type: "text/csv;charset=utf-8;"
    });
    const url = URL.createObjectURL(blob);
    const link = document.createElement("a");
    link.href = url;
    link.download = "contatos-exportados.csv";
    link.click();
    setTimeout(() => URL.revokeObjectURL(url), 100);
  });

  btnLimpar.addEventListener("click", () => {
    contatosCarregados = [];
    tabelaContainer.innerHTML = '<p class="vazio">Nenhum dado carregado. Importe um CSV para começar.</p>';
    statsEl.style.display = "none";
    btnExportar.disabled = true;
    btnLimpar.disabled = true;
    inputArquivo.value = "";
  });
</script>
</body>
</html>

Tarefa para você

Construa um analisador de vendas que:

1. Leia o CSV abaixo (cole como string no código)

data,produto,categoria,quantidade,preco_unitario,vendedor
2025-01-05,Notebook,Eletrônicos,2,3500,Ana
2025-01-07,Mouse,Eletrônicos,5,89.90,Bruno
2025-01-10,Cadeira,Móveis,1,900,Ana
2025-01-12,Teclado,Eletrônicos,3,199.90,Carlos
2025-01-15,Mesa,Móveis,2,700,Bruno
2025-01-18,Monitor,Eletrônicos,4,1200,Ana
2025-01-20,Notebook,Eletrônicos,1,3500,Carlos

2. Calcule e exiba:
   a) Total de receita geral
   b) Receita por categoria
   c) Receita por vendedor (quem vendeu mais?)
   d) Produto mais vendido (por quantidade)
   e) Ticket médio por venda

3. Exporte um relatório resumido em CSV com:
   vendedor, total_vendas, total_receita, ticket_medio
Ver solução — o analisador de vendas, com um parser que aguenta vírgula no campo
const CSV = `data,produto,categoria,quantidade,preco_unitario,vendedor
2025-01-05,Notebook,Eletrônicos,2,3500,Ana
2025-01-07,Mouse,Eletrônicos,5,89.90,Bruno
2025-01-10,Cadeira,Móveis,1,900,Ana
2025-01-12,Teclado,Eletrônicos,3,199.90,Carlos
2025-01-15,Mesa,Móveis,2,700,Bruno
2025-01-18,Monitor,Eletrônicos,4,1200,Ana
2025-01-20,Notebook,Eletrônicos,1,3500,Carlos`;

// ---------------------------------------------------------------
// 1 — o parser
// ---------------------------------------------------------------
// `linha.split(",")` resolve este arquivo e quebra no primeiro campo
// que contenha vírgula entre aspas — "Cadeira, giratória" viraria dois
// campos e desalinharia a linha inteira. O laço abaixo respeita as
// aspas e o escape de aspas duplas ("" dentro do campo).
function parsearCSV(texto) {
  const linhas = [];
  let campo = "";
  let linha = [];
  let dentroDeAspas = false;

  const conteudo = texto.replace(/\r\n/g, "\n"); // CRLF do Excel

  for (let i = 0; i < conteudo.length; i++) {
    const caractere = conteudo[i];

    if (dentroDeAspas) {
      if (caractere === '"') {
        if (conteudo[i + 1] === '"') { campo += '"'; i++; } // "" escapa aspas
        else dentroDeAspas = false;
      } else {
        campo += caractere;
      }
      continue;
    }

    if (caractere === '"') dentroDeAspas = true;
    else if (caractere === ",") { linha.push(campo); campo = ""; }
    else if (caractere === "\n") { linha.push(campo); linhas.push(linha); linha = []; campo = ""; }
    else campo += caractere;
  }

  if (campo !== "" || linha.length > 0) { linha.push(campo); linhas.push(linha); }

  const [cabecalho, ...corpo] = linhas;

  return corpo.map((colunas) =>
    Object.fromEntries(cabecalho.map((nome, i) => [nome, colunas[i] ?? ""]))
  );
}

const vendas = parsearCSV(CSV).map((linha) => ({
  data: new Date(`${linha.data}T00:00:00`), // sem o T00:00, vira UTC e volta um dia
  produto: linha.produto,
  categoria: linha.categoria,
  // Tudo que sai de um CSV é string. Sem Number(), "5" * "3500" até
  // funcionaria (o * converte), mas "5" + "3" daria "53".
  quantidade: Number(linha.quantidade),
  precoUnitario: Number(linha.preco_unitario),
  vendedor: linha.vendedor,
  get receita() {
    return this.quantidade * this.precoUnitario;
  },
}));

// ---------------------------------------------------------------
// 2 — as cinco análises
// ---------------------------------------------------------------
const reais = (n) => n.toLocaleString("pt-BR", { style: "currency", currency: "BRL" });

// Agrupador genérico: as três primeiras análises são a mesma pergunta
// com chave diferente, então vale escrever uma vez só.
function somarPor(itens, chave, valor) {
  return itens.reduce((mapa, item) => {
    const k = typeof chave === "function" ? chave(item) : item[chave];
    mapa[k] = (mapa[k] ?? 0) + valor(item);
    return mapa;
  }, {});
}

// a) receita geral
const receitaTotal = vendas.reduce((soma, v) => soma + v.receita, 0);
console.log(`a) Receita geral: ${reais(receitaTotal)}`);

// b) por categoria
const porCategoria = somarPor(vendas, "categoria", (v) => v.receita);
console.log("b) Por categoria:");
for (const [categoria, total] of Object.entries(porCategoria)) {
  const fatia = ((total / receitaTotal) * 100).toFixed(1);
  console.log(`   ${categoria.padEnd(14)} ${reais(total).padStart(14)}  (${fatia}%)`);
}

// c) por vendedor
const porVendedor = somarPor(vendas, "vendedor", (v) => v.receita);
const [campeao, receitaCampeao] = Object.entries(porVendedor).sort((a, b) => b[1] - a[1])[0];
console.log("c) Por vendedor:");
for (const [vendedor, total] of Object.entries(porVendedor).sort((a, b) => b[1] - a[1])) {
  console.log(`   ${vendedor.padEnd(8)} ${reais(total).padStart(14)}`);
}
console.log(`   → ${campeao} vendeu mais: ${reais(receitaCampeao)}`);

// d) produto mais vendido POR QUANTIDADE — não por receita. São
//    perguntas diferentes e costumam dar respostas diferentes.
const porProduto = somarPor(vendas, "produto", (v) => v.quantidade);
const [maisVendido, unidades] = Object.entries(porProduto).sort((a, b) => b[1] - a[1])[0];
console.log(`d) Mais vendido: ${maisVendido} (${unidades} unidades)`);

const porReceita = somarPor(vendas, "produto", (v) => v.receita);
const [maiorReceita] = Object.entries(porReceita).sort((a, b) => b[1] - a[1])[0];
console.log(`   (por receita, o líder é ${maiorReceita})`);

// e) ticket médio
const ticketMedio = receitaTotal / vendas.length;
console.log(`e) Ticket médio: ${reais(ticketMedio)} em ${vendas.length} vendas`);

// ---------------------------------------------------------------
// 3 — exportar o resumo em CSV
// ---------------------------------------------------------------
function escaparCSV(valor) {
  const texto = String(valor);
  // Só precisa de aspas quem contém vírgula, aspas ou quebra de linha.
  // Aspas internas viram duas.
  return /[",\n]/.test(texto) ? `"${texto.replace(/"/g, '""')}"` : texto;
}

function paraCSV(linhas, colunas) {
  return [
    colunas.join(","),
    ...linhas.map((linha) => colunas.map((c) => escaparCSV(linha[c])).join(",")),
  ].join("\n");
}

const vendasPorVendedor = somarPor(vendas, "vendedor", () => 1);

const resumo = Object.entries(porVendedor).map(([vendedor, receita]) => ({
  vendedor,
  total_vendas: vendasPorVendedor[vendedor],
  // toFixed(2) e não o formato brasileiro: num CSV, "1.234,56" com
  // vírgula decimal quebra a coluna se o separador também é vírgula.
  total_receita: receita.toFixed(2),
  ticket_medio: (receita / vendasPorVendedor[vendedor]).toFixed(2),
}));

const relatorioCSV = paraCSV(resumo, [
  "vendedor", "total_vendas", "total_receita", "ticket_medio",
]);

console.log("\n" + relatorioCSV);

// No navegador, isto vira download sem servidor nenhum:
//
//   const blob = new Blob(["\uFEFF" + relatorioCSV], {
//     type: "text/csv;charset=utf-8",
//   });
//   const link = document.createElement("a");
//   link.href = URL.createObjectURL(blob);
//   link.download = "resumo-vendas.csv";
//   link.click();
//   URL.revokeObjectURL(link.href);
//
// O "\uFEFF" é o BOM: sem ele o Excel abre o arquivo em latin-1 e
// "Eletrônicos" vira "Eletrônicos".

Duas armadilhas moram no CSV brasileiro: split(",") quebra em qualquer campo com vírgula entre aspas, e a vírgula decimal briga com o separador de colunas — number formatado em pt-BR dentro de CSV desalinha o arquivo. E new Date("2025-01-05") sem hora é interpretado como UTC: em fuso negativo, a data volta um dia.

A fama de formato simples não sobrevive ao primeiro campo com vírgula dentro. Aspas que escapam aspas, quebra de linha no meio do registro, ponto e vírgula no Excel em português, BOM para o acento não virar ç: cada regra dessas é pequena, e o conjunto é exatamente o motivo de existir o PapaParse. Escrever o parser do zero uma vez vale pelo entendimento; mantê-lo em produção raramente vale o risco.

Fontes e Referências

Exercícios

Exercício 1

O parser ingênuo do começo do artigo usa linha.split(","). Quantos campos ele encontra nesta linha, e o que acontece com o objeto resultante?

const cabecalho = ["produto", "descricao", "preco"];
const linha = 'Notebook,"Processador i7, 16GB RAM, SSD",3500.00';

const valores = linha.split(",");
console.log(valores.length);
Ver resposta

✓ Resposta: Encontra cinco campos, quando deveria encontrar três: Notebook, "Processador i7, 16GB RAM, SSD" e 3500.00. O split não sabe nada de aspas — ele corta em toda vírgula, inclusive nas que estão dentro do campo, e ainda deixa as aspas coladas nos pedaços das pontas. Como o cabeçalho tem três colunas, o reduce monta o objeto usando só os três primeiros valores e descarta os dois últimos: o produto fica com uma descrição truncada e o preço desaparece, substituído por um fragmento da descrição. Nada disso lança erro, e é essa a característica mais perigosa do CSV mal parseado — a importação "funciona", os números ficam errados, e a diferença só aparece quando alguém confere um total. Um campo com aspas exige percorrer a linha caractere a caractere, mantendo um estado de "estou dentro de aspas", que é exatamente o que a versão robusta do artigo faz.

Exercício 2

Um cliente do Rio manda a planilha exportada do Excel em português. Por que o parser quebra?

// arquivo recebido:
// produto;preco;quantidade
// Caneta;2,50;100
// Caderno;15,90;20

const dados = parsearCSVComTipos(texto); // delimitador padrão: ","
Ver resposta

✓ Resposta: Quebra duas vezes, por dois motivos que se combinam. O primeiro é o delimitador: o Excel em configuração brasileira separa colunas por ponto e vírgula, justamente porque a vírgula já está ocupada como separador decimal. Com o parser esperando vírgula, a linha inteira vira um único campo e o objeto sai com uma chave só. O segundo aparece mesmo depois de acertar o delimitador: o preço vem como 2,50, e a conversão automática de tipos faz Number("2,50"), que devolve NaN — sem erro, sem aviso, e todo cálculo a partir dali também vira NaN. Ou seja, o arquivo é CSV legítimo; o que não existe é um CSV universal. Um importador sério detecta o delimitador, e trata número decimal segundo a região do arquivo: Number("2,50".replace(".", "").replace(",", ".")) — a remoção do ponto vem primeiro porque no formato brasileiro ele é o separador de milhar.

Exercício 3

Este era o detector de delimitador do artigo, antes da correção. Por que ele sempre devolve a vírgula, mesmo num arquivo separado por ponto e vírgula?

const delimitadores = [",", ";", "\t", "|"];

const contagens = delimitadores.map(d => ({
  delimitador: d,
  contagem: (primeiraLinha.match(new RegExp(`\${d}`, "g")) || []).length,
}));
Ver resposta

✓ Resposta: Porque a interpolação não acontece. Dentro de um template literal, \$ é um cifrão escapado — vira o caractere $ literal, e o {d} que vem depois deixa de ser uma expressão para virar texto comum. A expressão regular construída é sempre a mesma, /${d}/, que procura um cifrão seguido de {d} e nunca casa com nada. Todas as contagens dão zero, o sort não tem o que ordenar e o primeiro item do array sai vencedor por ser o primeiro. E mesmo corrigindo o escape, o código continuaria errado: o | é o operador de alternância em expressões regulares, e new RegExp("|", "g") casa com a string vazia em toda posição — o pipe venceria sempre, em qualquer arquivo. Por isso a correção não foi consertar a interpolação, e sim abandonar a regex: primeiraLinha.split(d).length - 1 conta ocorrências de texto literal, sem metacaractere nenhum no caminho.

Exercício 4

O CSV exportado abre corretamente no VS Code, mas no Excel em Windows os acentos viram ç e ã. O que resolve — e por que a linha do BOM existe?

const bom = "";
const blob = new Blob([bom + csvTexto], { type: "text/csv;charset=utf-8;" });
Ver resposta

✓ Resposta: O BOM é o que resolve — e o problema é que ele estava faltando. O arquivo está em UTF-8 correto, mas o Excel em Windows, ao abrir um .csv com duplo clique, não tenta adivinhar a codificação: assume a página de código local, geralmente Windows-1252, e nela os dois bytes que formam o ç em UTF-8 são exibidos como dois caracteres separados, ç. O byte order mark  no início funciona como uma etiqueta que o Excel reconhece e que o faz mudar para UTF-8. Repare que o cabeçalho charset=utf-8 do Blob não ajuda nesse caso: ele descreve o tipo do conteúdo para o navegador, e depois que o arquivo está salvo no disco essa informação não acompanha. O custo do BOM é pequeno mas real — alguns parsers menos cuidadosos incluem os caracteres invisíveis no nome da primeira coluna, e aí a chave vira "nome" em vez de "nome", o que produz um undefined difícil de explicar na leitura de volta.

Exercício 5

Um campo de observação contém uma quebra de linha, digitada pelo usuário. O parser do artigo dá conta?

const csv = `id,cliente,observacao
1,Ana,"Entregar após as 18h.
Interfone quebrado."
2,Bruno,"Sem observações"`;

const dados = parsearCSVRobusto(csv);
console.log(dados.length);
Ver resposta

✓ Resposta: Não dá conta. Imprime 3 em vez de 2, e os dados saem embaralhados. O parser começa quebrando o texto inteiro por linha, com texto.split("\n"), e só depois analisa cada linha em busca de aspas — mas nesse ponto o estrago já foi feito, porque a observação da Ana foi partida ao meio e a segunda metade virou um registro solto com o campo id valendo Interfone quebrado.". Quebra de linha dentro de campo entre aspas é válida pela RFC 4180, e é comum em qualquer coluna de texto livre. Consertar exige inverter a ordem do trabalho: percorrer o arquivo inteiro caractere a caractere, mantendo o estado de "dentro de aspas", e só considerar que uma linha terminou quando o \n aparecer fora das aspas. É trabalhoso o suficiente para ser o argumento definitivo a favor de uma biblioteca — o PapaParse trata esse caso, e mais o BOM, os delimitadores e os finais de linha do Windows, sem que você precise pensar em nenhum deles.

Comentários

Mais em Javascript

Tratamento de erros em requisições HTTP
Tratamento de erros em requisições HTTP

Código que só funciona quando tudo dá certo não está pronto para produção…

Temporizadores: setTimeout e setInterval
Temporizadores: setTimeout e setInterval

Um relógio que desconta um segundo a cada tique sempre atrasa: setInterval…

React Hooks em profundidade
React Hooks em profundidade

Depois do useState e do useEffect vem o resto: useRef para o que persiste sem…