Convertidor de XML a CSV

Aplana a CSV, y te avisa cuando la estructura no encaja.

Entrada
Salida
En esperaPega un documento para comprobarlo. La validación se ejecuta mientras escribes.

Todo se ejecuta en esta pestaña. Nada de lo que pegues se sube, se registra ni se envía a ningún sitio. Abre el panel de red y compruébalo.

Pega un documento XML con lista de registros y se convierte en una tabla separada por comas que puedes abrir en Excel o cargar con pandas. El elemento de fila se detecta por ti, los hijos anidados se convierten en nombres de columna con puntos como address.city, y los atributos se convierten en columnas con el prefijo @. El escáner, el achatador y el escritor de CSV corren en un Web Worker de esta pestaña, así que nada se sube y nada queda detrás de una cuenta.

Recurres a esto cuando algo aguas arriba solo habla XML y algo aguas abajo solo habla filas: un feed de productos, una exportación de extracto bancario, un informe de un ERP antiguo, una respuesta de API que quieres mirar antes de escribir código contra ella. Es también la forma más rápida de meter un sitemap en una columna de URLs.

Lo distinto es que la conversión te dice qué no pudo representar. CSV es un rectángulo y XML es un árbol, así que algunos documentos se achatan exactos y otros pierden datos. Cuando elementos repetidos se colapsan en una sola celda, el panel lo dice y da el recuento; cuando el elemento de fila fue adivinado, nombra lo que eligió. La mayoría de los conversores te entregan un archivo y te dejan que lo notes tú.

CSV es un rectángulo y XML es un árbol

El RFC 4180 pone la restricción en una línea: «Cada línea debería contener el mismo número de campos a lo largo del archivo». XML no tiene esa regla, y ese desajuste es toda la dificultad. La conversión es fiel solo cuando el XML ya es tabular: un registro que se repite y cuyos hijos son hojas de valor único.

Cuando un registro contiene un hijo repetido, un <order> que lleva tres elementos <line>, un conversor tiene tres opciones: explotar en una fila por línea y duplicar las columnas del padre, colapsar las repeticiones en una celda, o inventar columnas line.1.sku y line.2.sku cuyo número depende del registro más ancho. Las tres pierden algo, no hay una cuarta, y esta herramienta toma la segunda y lo informa.

  • Se achata exacto: un elemento repetido bajo la raíz, hijos hoja, atributos en el registro o en sus hojas.
  • Se achata con pérdida: un registro que contiene un hijo repetido. Los valores se unen y el recuento se informa.
  • No se achata: contenido mixto, hermanos heterogéneos, o un documento cuyo anidamiento es lo importante. Usa JSON.

Cómo se eligen el elemento de fila y las columnas

Deja vacía la casilla Elemento de fila y el hijo elemento más frecuente de la raíz se convierte en la fila, que es lo correcto para casi toda entrada de XML a CSV. La decisión se imprime en lugar de ocultarse: «Las filas se tomaron de los 2 elementos <book> bajo <catalog>». Si la adivinanza falla, escribe el nombre tú.

Hay un límite que conviene conocer antes de pegar. El elemento de fila debe ser hijo directo de la raíz, así que un documento que envuelve su lista un nivel más abajo, <catalog><books><book/><book/></books></catalog>, da una sola fila para <books>. Quita esa envoltura, o convierte a JSON, donde el anidamiento sobrevive.

Las columnas son la unión de cada ruta a lo largo de todas las filas, en el orden en que se vieron, así que un campo que solo algunos registros llevan igualmente recibe columna y el resto reciben celda vacía. Los atributos caen a la profundidad en que ocurren, así que un atributo currency en price se convierte en price.@currency en vez de chocar con price. Los prefijos de espacio de nombres se mantienen literales, las entidades y CDATA se resuelven, el texto de las hojas se recorta.

<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
El documento de muestra y las columnas que produce. Los campos se entrecomillan solo cuando el RFC 4180 lo exige.

Elementos repetidos, y exactamente qué se pierde

Cuando un registro lleva el mismo elemento hoja más de una vez, los valores se unen con una barra vertical en una sola celda y se cuentan. La celda sigue escapándose bien, así que una coma dentro de uno de esos valores no rompe las columnas. Lo que pierdes es el límite: un valor que ya contiene una barra vertical se vuelve ambiguo, y el separador no es configurable.

Un caso que la unión no cubre: cuando el elemento repetido tiene hijos propios, cada aparición escribe en las mismas columnas con puntos, así que gana la última y las anteriores se sobrescriben sin nota. Las extensiones de imagen dentro de un <url> de sitemap, y las líneas de pedido con varios campos cada una, se comportan así las dos. Sácalas con XPath, o convierte a JSON.

Sitemaps, y el uso SEO de esto

Un sitemap es una lista de registros, así que convierte sin configuración: la raíz es <urlset>, el hijo repetido es <url>, y obtienes loc, lastmod, changefreq y priority como columnas, una fila por URL. Eso basta para ordenar por fecha de modificación, contar contra el límite de 50.000 URLs, o pegar la columna loc al lado de una exportación de rastreo para encontrar las páginas que están en una y no en la otra. Un índice de sitemaps convierte igual.

Dos hechos que llevarte a la hoja de cálculo. Google declara que ignora priority y changefreq, así que esas columnas auditan lo que emite tu CMS y nada más. Y Google usa lastmod solo cuando es «consistente y verificablemente exacto», así que una columna donde todas las filas comparten una marca de tiempo, o llevan una fecha futura, es una que Google probablemente descarte. Para comprobar reglas en lugar de extraer, usa mejor el validador de sitemaps, que impone los topes, el vocabulario de changefreq y el formato W3C Datetime.

Hacer esto desde código

El mismo achatado en los lenguajes que consumen XML. Leer XML es la dirección peligrosa, así que cada ejemplo usa la configuración segura del analizador: entidades externas apagadas, carga de DTD apagada, sin red. La escritura del CSV es explícita, porque el escapado es donde suelen romperse las exportaciones hechas a mano.

// 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; ...'

Todos los ejemplos desactivan algo en el analizador. El comportamiento inseguro es el predeterminado en Java y en la biblioteca estándar de Python, y la conversión es justo el contexto en el que el archivo llegó de un proveedor, un cliente o una bandeja de entrada y no de ti.

Preguntas frecuentes

¿Se sube mi XML cuando lo convierto?

No. El escáner, el achatador y el escritor de CSV son JavaScript corriendo en un Web Worker de esta pestaña, y no hay back end al que mandar nada. Abre tu panel de red y convierte algo: los recursos de la página cargan una vez, y después nada.

Eso importa más en la conversión que en la validación, por lo que son estos archivos. Nadie convierte a CSV un fragmento de tutorial. La gente convierte exportaciones de clientes, historiales de pedidos y listas de precios de proveedores. Tu entrada se guarda en el localStorage de este navegador para que una recarga no la pierda, y Limpiar la elimina.

¿Por qué obtuve una fila en lugar de cientos?

Casi siempre porque la lista está un nivel por debajo de la raíz. El elemento de fila se elige entre los hijos directos de la raíz, así que <catalog><books><book/><book/></books></catalog> ve un hijo <books>, da una fila, y achata cada <book> en las mismas columnas ganando el último.

Quita la envoltura para que el elemento repetido sea hijo directo, o convierte a JSON. Nombrar <book> en la casilla Elemento de fila no ayuda: informa de que no se encontró ningún <book> bajo la raíz, lo cual es cierto.

¿Qué significa la barra vertical en esa celda?

CSV no puede expresar un campo que aparece más de una vez, así que varias copias del mismo elemento hoja se unen con una barra vertical y el panel informa de cuántas veces pasó. La celda se escapa como es debido, así que las comas dentro de esos valores no rompen las columnas. La pérdida es el límite: un valor que ya contiene una barra vertical queda ahora ambiguo.

Cuando el elemento repetido tiene hijos propios, la unión no se aplica. Cada aparición escribe en las mismas columnas con puntos y la última sobrescribe al resto. Extrae esas con XPath, o usa JSON, donde las repeticiones se convierten en un array.

¿Se abrirá bien el resultado en Excel?

Normalmente sí, con dos salvedades que se le echan en cara al conversor. Excel en Windows lee un archivo UTF-8 pelado como la página de códigos del sistema a menos que empiece con una marca de orden de bytes, lo que convierte los nombres con acentos en galimatías: importa con Datos y luego Desde texto/CSV y pon UTF-8 en vez de hacer doble clic.

Excel también reformatea los valores que reconoce. Un código de producto 0012 pierde sus ceros, 1-2 se convierte en fecha, un identificador largo se convierte en notación científica. Marca esas columnas como Texto al importar, y si tu configuración regional separa por punto y coma, cambia el control Delimitador de arriba.

¿Qué tamaño de archivo puedo convertir?

Hasta 20 millones de caracteres, unos 20 MB, sin cuenta, sin cola y sin niveles de tamaño, porque no hay servidor que los mida. La herramienta XML gratuita más visible de este ámbito limita la entrada a 512 KB y te pide confirmar que tus datos se guardan en sus servidores.

El límite es de memoria y no de política: todo se mantiene en esta pestaña. Por debajo de él el coste es lineal, 1 MB en unos 110 milisegundos y 10 MB en algo más de un segundo. Por encima, procesa en local en flujo con iterparse, SAX o xmlstarlet.

¿Debería convertir a CSV o a JSON?

CSV cuando el destino es una hoja de cálculo, una importación masiva que espera columnas, o una persona que va a ordenar y filtrar. JSON cuando los datos tienen una estructura que merece conservarse.

La prueba es rápida: mira un registro y pregúntate si cada campo aparece exactamente una vez y lleva un solo valor. Si sí, CSV es fiel. Si alguno se repite o lleva subcampos, estarás reconstruyendo esa estructura más tarde a partir de delimitadores dentro de celdas.

Herramientas relacionadas

Lecturas de referencia