Conversor de XML para CSV

Achata para CSV e avisa quando a estrutura não couber.

Entrada
Saída
AguardandoCole um documento para verificá-lo. A validação roda enquanto você digita.

Tudo roda nesta aba. Nada do que você colar é enviado, registrado ou transmitido para lugar nenhum. Abra o painel de rede e confira.

Cole um documento XML com lista de registros e ele se torna uma tabela separada por vírgulas que você pode abrir no Excel ou carregar com pandas. O elemento de linha é detectado para você, filhos aninhados viram nomes de coluna pontilhados como address.city, e atributos viram colunas com o prefixo @. O analisador, o achatador e o escritor de CSV rodam num Web Worker desta aba, então nada é enviado e nada fica trancado atrás de uma conta.

Você recorre a isto quando algo rio acima só fala XML e algo rio abaixo só fala linhas: um feed de produtos, uma exportação de extrato bancário, um relatório saído de um ERP antigo, uma resposta de API que você quer olhar antes de escrever código contra ela. É também o jeito mais rápido de virar um sitemap numa coluna de URLs.

O que é diferente é que a conversão lhe diz o que não conseguiu representar. CSV é um retângulo e XML é uma árvore, então alguns documentos achatam exatamente e outros perdem dados. Quando elementos repetidos são dobrados numa única célula, o painel diz isso e dá a contagem; quando o elemento de linha foi adivinhado, ele nomeia o que escolheu. A maioria dos conversores lhe entrega um arquivo e deixa que você perceba.

CSV é um retângulo e XML é uma árvore

A RFC 4180 põe a restrição numa linha: «Cada linha deve conter o mesmo número de campos ao longo do arquivo.» O XML não tem essa regra, e esse desencontro é toda a dificuldade. A conversão só é fiel quando o XML já é tabular: um registro que se repete e cujos filhos são folhas de valor único.

Quando um registro contém um filho repetido, um <order> carregando três elementos <line>, um conversor tem três opções: explodir numa linha por line e duplicar as colunas do pai, dobrar as repetições numa célula, ou inventar colunas line.1.sku e line.2.sku cujo número depende do registro mais largo. As três perdem algo, não existe uma quarta, e esta ferramenta escolhe a segunda e relata isso.

  • Achata exatamente: um elemento repetido sob a raiz, filhos folha, atributos no registro ou nas suas folhas.
  • Achata com perda: um registro contendo um filho repetido. Os valores são juntados e a contagem é relatada.
  • Não achata: conteúdo misto, irmãos heterogêneos, ou um documento cujo aninhamento é justamente o ponto. Use JSON.

Como o elemento de linha e as colunas são escolhidos

Deixe a caixa Elemento de linha vazia e o filho elemento mais frequente da raiz se torna a linha, o que está certo para quase toda entrada de XML para CSV. A decisão é impressa em vez de escondida: «As linhas foram tiradas dos 2 elementos <book> sob <catalog>». Se o palpite estiver errado, digite o nome você mesmo.

Vale conhecer um limite antes de colar. O elemento de linha precisa ser filho direto da raiz, então um documento que embrulha a sua lista um nível mais fundo, <catalog><books><book/><book/></books></catalog>, dá uma única linha para <books>. Tire esse embrulho, ou converta para JSON, onde o aninhamento sobrevive.

As colunas são a união de todos os caminhos ao longo de todas as linhas, na ordem em que foram vistos, então um campo que só alguns registros carregam ainda assim ganha coluna e os demais ganham célula vazia. Atributos pousam na profundidade em que ocorrem, então um atributo currency em price vira price.@currency em vez de colidir com price. Prefixos de espaço de nomes são mantidos literalmente, entidades e CDATA são resolvidos, o texto das folhas é aparado.

<catalog xmlns:dc="http://purl.org/dc/elements/1.1/">
  <book id="bk101" available="true">
    <dc:title>XML Developer's Guide</dc:title>
    <price currency="GBP">44.95</price>
  </book>
</catalog>

@id,@available,dc:title,price.@currency,price
bk101,true,XML Developer's Guide,GBP,44.95
O documento de amostra e as colunas que ele produz. Os campos só recebem aspas quando a RFC 4180 exige.

Elementos repetidos, e exatamente o que se perde

Quando um registro carrega o mesmo elemento folha mais de uma vez, os valores são juntados com uma barra vertical numa única célula e contados. A célula continua escapada corretamente, então uma vírgula dentro de um desses valores não quebra as colunas. O que você perde é a fronteira: um valor que já contém uma barra vertical fica ambíguo, e o separador não é configurável.

Um caso que a junção não cobre: quando o elemento repetido tem filhos próprios, cada ocorrência escreve nas mesmas colunas pontilhadas, então a última ganha e as anteriores são sobrescritas sem aviso. Extensões de imagem dentro de um <url> de sitemap, e itens de pedido com vários campos cada, se comportam assim. Tire esses com XPath, ou converta para JSON.

Sitemaps, e o uso de SEO disto

Um sitemap é uma lista de registros, então converte sem configuração: a raiz é <urlset>, o filho repetido é <url>, e você ganha loc, lastmod, changefreq e priority como colunas, uma linha por URL. Isso basta para ordenar por data de modificação, contar contra o limite de 50.000 URLs, ou colar a coluna loc ao lado de uma exportação de rastreamento para achar as páginas que estão numa e não na outra. Um índice de sitemaps converte do mesmo jeito.

Dois fatos para levar para a planilha. O Google declara que ignora priority e changefreq, então essas colunas auditam o que o seu CMS emite, e nada mais. E o Google usa lastmod apenas quando ele é «consistentemente e verificavelmente exato», então uma coluna em que todas as linhas compartilham o mesmo carimbo de tempo, ou trazem uma data futura, é uma coluna que o Google provavelmente vai descartar. Para checar regras em vez de extrair, use o validador de sitemaps, que impõe os limites, o vocabulário de changefreq e o formato W3C Datetime.

Fazer isso em código

O mesmo achatamento nas linguagens que consomem XML. Ler XML é a direção perigosa, então cada exemplo usa a configuração segura do analisador: entidades externas desligadas, carregamento de DTD desligado, sem rede. A escrita do CSV é explícita, porque o escape é onde as exportações feitas à mão costumam quebrar.

// Browser or Deno. DOMParser never resolves external entities, so XXE is
// not reachable here. In Node use @xmldom/xmldom, which also does not.
function xmlToCsv(source, rowTag) {
  const doc = new DOMParser().parseFromString(source, 'application/xml');
  const error = doc.querySelector('parsererror');
  if (error) throw new Error(error.textContent.trim());

  const root = doc.documentElement;
  const rows = [...root.children].filter((el) => el.tagName === rowTag);
  if (rows.length === 0) throw new Error('No <' + rowTag + '> under <' + root.tagName + '>');

  const columns = [];
  const records = rows.map((row) => {
    const record = {};
    const put = (key, value) => {
      if (!columns.includes(key)) columns.push(key);
      // Repeated leaves are joined. That is lossy; count it in real code.
      record[key] = record[key] === undefined ? value : record[key] + '|' + value;
    };
    const walk = (el, prefix) => {
      for (const a of el.attributes) {
        put(prefix ? prefix + '.@' + a.name : '@' + a.name, a.value);
      }
      const kids = [...el.children];
      if (kids.length === 0) {
        put(prefix || el.tagName, el.textContent.trim());
        return;
      }
      for (const k of kids) walk(k, prefix ? prefix + '.' + k.tagName : k.tagName);
    };
    walk(row, '');
    return record;
  });

  // RFC 4180: quote a field containing a comma, a quote or a line break,
  // and double any quote inside it.
  const cell = (v) => (/[",\r\n]/.test(v) ? '"' + v.replace(/"/g, '""') + '"' : v);
  const line = (values) => values.map((v) => cell(v ?? '')).join(',');

  return [line(columns), ...records.map((r) => line(columns.map((c) => r[c])))].join('\r\n');
}
# pip install defusedxml
# The standard library parser is not safe against entity expansion.
# defusedxml is a drop-in replacement that closes that and external entities.
import csv
import sys
from defusedxml.ElementTree import parse


def flatten(el, prefix, record, columns):
    for name, value in el.attrib.items():
        key = f"{prefix}.@{name}" if prefix else f"@{name}"
        if key not in columns:
            columns.append(key)
        record[key] = value

    children = list(el)
    if not children:
        key = prefix or el.tag
        if key not in columns:
            columns.append(key)
        record[key] = (el.text or "").strip()
        return

    for child in children:
        # ElementTree reports namespaced names in Clark notation,
        # '{http://purl.org/dc/elements/1.1/}title', not 'dc:title'.
        tag = child.tag.split("}")[-1]
        key = f"{prefix}.{tag}" if prefix else tag
        if key in record:  # repeated leaf: join, and note the loss
            record[key] += "|" + (child.text or "").strip()
        else:
            flatten(child, key, record, columns)


def xml_to_csv(path, row_tag, out):
    root = parse(path).getroot()
    columns, records = [], []
    for row in root.findall(row_tag):
        record = {}
        flatten(row, "", record, columns)
        records.append(record)
    writer = csv.DictWriter(out, fieldnames=columns, restval="", extrasaction="ignore")
    writer.writeheader()
    writer.writerows(records)


# newline="" or csv writes \r\r\n on Windows.
# utf-8-sig writes the BOM Excel needs to read the file as UTF-8.
with open("out.csv", "w", newline="", encoding="utf-8-sig") as out:
    xml_to_csv(sys.argv[1], sys.argv[2], out)
import java.io.*;
import java.nio.charset.StandardCharsets;
import java.util.*;
import javax.xml.XMLConstants;
import javax.xml.parsers.*;
import org.w3c.dom.*;

public class XmlToCsv {

    static DocumentBuilder secureBuilder() throws Exception {
        DocumentBuilderFactory f = DocumentBuilderFactory.newInstance();
        // None of this is the default. Without it a document can read files
        // off the machine running the conversion.
        f.setFeature(XMLConstants.FEATURE_SECURE_PROCESSING, true);
        f.setFeature("http://apache.org/xml/features/disallow-doctype-decl", true);
        f.setFeature("http://xml.org/sax/features/external-general-entities", false);
        f.setFeature("http://xml.org/sax/features/external-parameter-entities", false);
        f.setXIncludeAware(false);
        f.setExpandEntityReferences(false);
        return f.newDocumentBuilder();
    }

    static void flatten(Element el, String prefix, Map<String, String> rec, List<String> cols) {
        NamedNodeMap attrs = el.getAttributes();
        for (int i = 0; i < attrs.getLength(); i++) {
            Node a = attrs.item(i);
            String key = prefix.isEmpty() ? "@" + a.getNodeName() : prefix + ".@" + a.getNodeName();
            if (!cols.contains(key)) cols.add(key);
            rec.put(key, a.getNodeValue());
        }

        List<Element> kids = new ArrayList<>();
        NodeList children = el.getChildNodes();
        for (int i = 0; i < children.getLength(); i++) {
            if (children.item(i) instanceof Element e) kids.add(e);
        }

        if (kids.isEmpty()) {
            String key = prefix.isEmpty() ? el.getNodeName() : prefix;
            if (!cols.contains(key)) cols.add(key);
            rec.put(key, el.getTextContent().trim());
            return;
        }
        for (Element k : kids) {
            String key = prefix.isEmpty() ? k.getNodeName() : prefix + "." + k.getNodeName();
            if (rec.containsKey(key)) {
                rec.put(key, rec.get(key) + "|" + k.getTextContent().trim());
            } else {
                flatten(k, key, rec, cols);
            }
        }
    }

    static String cell(String v) {
        if (v == null) return "";
        boolean needsQuotes = v.indexOf(',') >= 0 || v.indexOf('"') >= 0
                || v.indexOf('\r') >= 0 || v.indexOf('\n') >= 0;
        return needsQuotes ? '"' + v.replace("\"", "\"\"") + '"' : v;
    }

    public static void main(String[] args) throws Exception {
        Document doc = secureBuilder().parse(new File(args[0]));
        List<String> cols = new ArrayList<>();
        List<Map<String, String>> recs = new ArrayList<>();

        NodeList rows = doc.getDocumentElement().getElementsByTagName(args[1]);
        for (int i = 0; i < rows.getLength(); i++) {
            Map<String, String> rec = new LinkedHashMap<>();
            flatten((Element) rows.item(i), "", rec, cols);
            recs.add(rec);
        }

        try (PrintWriter out = new PrintWriter(new OutputStreamWriter(
                new FileOutputStream("out.csv"), StandardCharsets.UTF_8))) {
            out.print(String.join(",", cols.stream().map(XmlToCsv::cell).toList()) + "\r\n");
            for (Map<String, String> rec : recs) {
                out.print(String.join(",",
                        cols.stream().map(c -> cell(rec.get(c))).toList()) + "\r\n");
            }
        }
    }
}
using System.Text;
using System.Xml;
using System.Xml.Linq;

// A null XmlResolver means an external DTD or entity is never fetched.
var settings = new XmlReaderSettings
{
    DtdProcessing = DtdProcessing.Prohibit,
    XmlResolver = null,
    MaxCharactersFromEntities = 1024 * 1024,
};

using var reader = XmlReader.Create(args[0], settings);
var doc = XDocument.Load(reader);
var rowName = args[1];

var columns = new List<string>();
var records = new List<Dictionary<string, string>>();

void Flatten(XElement el, string prefix, Dictionary<string, string> rec)
{
    foreach (var a in el.Attributes())
    {
        if (a.IsNamespaceDeclaration) continue;   // xmlns is not data
        var key = prefix.Length == 0 ? "@" + a.Name.LocalName : prefix + ".@" + a.Name.LocalName;
        if (!columns.Contains(key)) columns.Add(key);
        rec[key] = a.Value;
    }

    var kids = el.Elements().ToList();
    if (kids.Count == 0)
    {
        var key = prefix.Length == 0 ? el.Name.LocalName : prefix;
        if (!columns.Contains(key)) columns.Add(key);
        rec[key] = el.Value.Trim();
        return;
    }

    foreach (var k in kids)
    {
        var key = prefix.Length == 0 ? k.Name.LocalName : prefix + "." + k.Name.LocalName;
        if (rec.ContainsKey(key)) rec[key] += "|" + k.Value.Trim();
        else Flatten(k, key, rec);
    }
}

foreach (var row in doc.Root!.Elements().Where(e => e.Name.LocalName == rowName))
{
    var rec = new Dictionary<string, string>();
    Flatten(row, "", rec);
    records.Add(rec);
}

static string Cell(string? v)
{
    if (v is null) return "";
    return v.IndexOfAny(new[] { ',', '"', '\r', '\n' }) >= 0
        ? "\"" + v.Replace("\"", "\"\"") + "\""
        : v;
}

// UTF8Encoding(true) writes a BOM, which is what makes Excel on Windows
// read the file as UTF-8 rather than the system code page.
using var writer = new StreamWriter("out.csv", false, new UTF8Encoding(true));
writer.WriteLine(string.Join(",", columns.Select(Cell)));
foreach (var rec in records)
{
    writer.WriteLine(string.Join(",", columns.Select(c => Cell(rec.GetValueOrDefault(c)))));
}
<?php
// LIBXML_NONET blocks network access during the parse. PHP 8 does not load
// external entities by default; passing the flag keeps this correct on PHP 7
// and documents the intent.
libxml_use_internal_errors(true);

$doc = new DOMDocument();
if (!$doc->load($argv[1], LIBXML_NONET)) {
    foreach (libxml_get_errors() as $e) {
        fprintf(STDERR, "line %d: %s", $e->line, $e->message);
    }
    exit(1);
}

$rowName = $argv[2];
$columns = [];
$records = [];

$flatten = function (DOMElement $el, string $prefix, array &$rec) use (&$flatten, &$columns) {
    foreach ($el->attributes as $a) {
        $key = $prefix === '' ? '@' . $a->name : $prefix . '.@' . $a->name;
        if (!in_array($key, $columns, true)) $columns[] = $key;
        $rec[$key] = $a->value;
    }

    $kids = [];
    foreach ($el->childNodes as $n) {
        if ($n instanceof DOMElement) $kids[] = $n;
    }

    if (!$kids) {
        $key = $prefix === '' ? $el->nodeName : $prefix;
        if (!in_array($key, $columns, true)) $columns[] = $key;
        $rec[$key] = trim($el->textContent);
        return;
    }

    foreach ($kids as $k) {
        $key = $prefix === '' ? $k->nodeName : $prefix . '.' . $k->nodeName;
        if (array_key_exists($key, $rec)) {
            $rec[$key] .= '|' . trim($k->textContent);
        } else {
            $flatten($k, $key, $rec);
        }
    }
};

foreach ($doc->documentElement->childNodes as $node) {
    if ($node instanceof DOMElement && $node->nodeName === $rowName) {
        $rec = [];
        $flatten($node, '', $rec);
        $records[] = $rec;
    }
}

$out = fopen('out.csv', 'w');
fwrite($out, "\xEF\xBB\xBF");        // BOM, for Excel
fputcsv($out, $columns);              // fputcsv applies RFC 4180 quoting
foreach ($records as $rec) {
    fputcsv($out, array_map(fn($c) => $rec[$c] ?? '', $columns));
}
fclose($out);
# xmlstarlet is the quickest route for a one-off extraction.
# --net=false stops it fetching a DTD or an external entity. Not the default.
xmlstarlet sel --net=false -t \
  -o 'id,title,price' -n \
  -m '/catalog/book' \
    -v '@id' -o ',' \
    -v 'title' -o ',' \
    -v 'price' -n \
  catalog.xml > out.csv

# It does no CSV quoting at all. A title containing a comma, a quote or a
# newline breaks the column alignment silently, so either be certain the
# data is clean or re-quote the output:
xmlstarlet sel --net=false -t -m '/catalog/book' \
  -v 'concat(@id,",",title)' -n catalog.xml | csvformat -   # csvkit

# For files too large to hold in memory, iterate instead of loading:
#   python -c 'from xml.etree.ElementTree import iterparse; ...'

Todo exemplo desliga algo no analisador. O comportamento inseguro é o padrão em Java e na biblioteca padrão do Python, e a conversão é exatamente o contexto em que o arquivo chegou de um fornecedor, de um cliente ou de uma caixa de entrada, e não de você.

Perguntas frequentes

Meu XML é enviado quando eu o converto?

Não. O analisador, o achatador e o escritor de CSV são JavaScript rodando num Web Worker desta aba, e não há back end para onde mandar nada. Abra o painel de rede e converta algo: os recursos da página carregam uma vez, e depois nada.

Isso importa mais na conversão do que na validação, pelo que esses arquivos são. Ninguém converte um trecho de tutorial para CSV. As pessoas convertem exportações de clientes, históricos de pedidos e listas de preços de fornecedores. Sua entrada fica no localStorage deste navegador para que um recarregamento não a perca, e Limpar a remove.

Por que eu recebi uma linha em vez de centenas?

Quase sempre porque a lista está um nível abaixo da raiz. O elemento de linha é escolhido entre os filhos diretos da raiz, então <catalog><books><book/><book/></books></catalog> vê um filho <books>, dá uma linha, e achata cada <book> nas mesmas colunas com a última ganhando.

Remova o embrulho para que o elemento repetido seja filho direto, ou converta para JSON. Nomear <book> na caixa Elemento de linha não ajuda: ele relata que nenhum <book> foi encontrado sob a raiz, o que é verdade.

O que significa a barra vertical naquela célula?

O CSV não consegue expressar um campo que ocorre mais de uma vez, então várias cópias do mesmo elemento folha são juntadas com uma barra vertical e o painel relata quantas vezes isso aconteceu. A célula é escapada como deve, então vírgulas dentro desses valores não quebram as colunas. A perda é a fronteira: um valor que já contém uma barra vertical fica agora ambíguo.

Quando o elemento repetido tem filhos próprios, a junção não se aplica. Cada ocorrência escreve nas mesmas colunas pontilhadas e a última sobrescreve as demais. Extraia esses com XPath, ou use JSON, onde as repetições viram um vetor.

O resultado vai abrir direito no Excel?

Normalmente sim, com duas ressalvas que acabam sendo culpa do conversor. O Excel no Windows lê um arquivo UTF-8 puro como a página de código do sistema, a menos que ele comece com uma marca de ordem de bytes, o que transforma nomes acentuados em sopa de letras: importe por Dados e depois De Texto/CSV e escolha UTF-8 em vez de dar dois cliques.

O Excel também reformata valores que reconhece. Um código de produto 0012 perde os zeros, 1-2 vira data, um identificador longo vira notação científica. Marque essas colunas como Texto na importação, e se a sua configuração regional separa por ponto e vírgula, troque o controle de Delimitador acima.

Que tamanho de arquivo eu posso converter?

Até 20 milhões de caracteres, cerca de 20 MB, sem conta, sem fila e sem faixa de tamanho, porque não há servidor para medir nada. A ferramenta XML gratuita mais visível nesse espaço limita a entrada a 512 KB e pede que você confirme que seus dados ficam guardados nos servidores dela.

O limite é de memória, não de política: tudo é mantido nesta aba. Abaixo dele o custo é linear, 1 MB em cerca de 110 milissegundos e 10 MB em pouco mais de um segundo. Acima, processe em fluxo localmente com iterparse, SAX ou xmlstarlet.

Devo converter para CSV ou para JSON?

CSV quando o destino é uma planilha, uma importação em massa que espera colunas, ou uma pessoa que vai ordenar e filtrar. JSON quando os dados têm estrutura que vale a pena guardar.

O teste é rápido: olhe um registro e pergunte se cada campo aparece exatamente uma vez e traz um único valor. Se sim, o CSV é fiel. Se algum se repete ou carrega subcampos, você vai reconstruir essa estrutura depois, a partir de delimitadores dentro de células.

Ferramentas relacionadas

Leitura complementar