XML-zu-CSV-Konverter

Flacht zu CSV ab und sagt, wenn die Struktur nicht passt.

Eingabe
Ausgabe
WartetFügen Sie ein Dokument ein. Die Prüfung läuft während der Eingabe.

Alles läuft in diesem Tab. Nichts, was Sie einfügen, wird hochgeladen, protokolliert oder irgendwohin gesendet. Öffnen Sie Ihr Netzwerkpanel und prüfen Sie es.

Fügen Sie ein XML-Dokument mit einer Datensatzliste ein, und es wird zu einer kommagetrennten Tabelle, die Sie in Excel öffnen oder mit pandas laden können. Das Zeilenelement wird für Sie erkannt, verschachtelte Kinder werden zu gepunkteten Spaltennamen wie address.city, und Attribute werden zu Spalten mit dem Präfix @. Scanner, Flachmacher und CSV-Schreiber laufen in einem Web Worker in diesem Tab, es wird also nichts hochgeladen und nichts hinter einem Konto weggeschlossen.

Man greift danach, wenn etwas stromaufwärts nur XML spricht und etwas stromabwärts nur Zeilen: ein Produktfeed, ein Kontoauszugsexport, ein Bericht aus einem alten ERP, eine API-Antwort, die Sie ansehen wollen, bevor Sie Code dagegen schreiben. Es ist außerdem der schnellste Weg, eine Sitemap in eine Spalte von URLs zu verwandeln.

Der Unterschied ist, dass die Umwandlung Ihnen sagt, was sie nicht darstellen konnte. CSV ist ein Rechteck und XML ist ein Baum, manche Dokumente lassen sich also exakt flachlegen und manche verlieren Daten. Werden wiederholte Elemente in eine Zelle zusammengefaltet, sagt das Panel es und nennt die Anzahl; wurde das Zeilenelement geraten, nennt es seine Wahl. Die meisten Konverter reichen Ihnen eine Datei und lassen Sie es selbst merken.

CSV ist ein Rechteck und XML ist ein Baum

Die RFC 4180 setzt die Bedingung in eine Zeile: „Jede Zeile sollte in der ganzen Datei dieselbe Anzahl von Feldern enthalten.“ XML hat keine solche Regel, und dieses Missverhältnis ist die ganze Schwierigkeit. Die Umwandlung ist nur dann treu, wenn das XML schon tabellarisch ist: ein wiederholter Datensatz, dessen Kinder einwertige Blätter sind.

Enthält ein Datensatz ein wiederholtes Kind – ein <order> mit drei <line>-Elementen –, hat ein Konverter drei Möglichkeiten: in eine Zeile je line auseinanderziehen und die Elternspalten duplizieren, die Wiederholungen in eine Zelle falten, oder Spalten line.1.sku und line.2.sku erfinden, deren Zahl vom breitesten Datensatz abhängt. Alle drei verlieren etwas, eine vierte gibt es nicht, und dieses Werkzeug nimmt die zweite und meldet sie.

  • Legt exakt flach: ein wiederholtes Element unter der Wurzel, Blattkinder, Attribute am Datensatz oder an seinen Blättern.
  • Legt mit Verlust flach: ein Datensatz mit einem wiederholten Kind. Werte werden verbunden und die Anzahl gemeldet.
  • Legt nicht flach: gemischter Inhalt, ungleichartige Geschwister, oder ein Dokument, dessen Verschachtelung der ganze Punkt ist. Nehmen Sie JSON.

Wie Zeilenelement und Spalten gewählt werden

Lassen Sie das Feld „Zeilenelement“ leer, und das häufigste Elementkind der Wurzel wird die Zeile, was für fast jede XML-zu-CSV-Eingabe richtig ist. Die Entscheidung wird gedruckt statt versteckt: „Zeilen wurden den 2 <book>-Elementen unter <catalog> entnommen“. Ist die Vermutung falsch, tippen Sie den Namen selbst.

Eine Grenze ist vor dem Einfügen zu kennen. Das Zeilenelement muss ein direktes Kind der Wurzel sein, ein Dokument also, das seine Liste eine Ebene tiefer verpackt – <catalog><books><book/><book/></books></catalog> –, gibt eine einzige Zeile für <books>. Entfernen Sie diese Verpackung, oder wandeln Sie nach JSON um, wo die Verschachtelung überlebt.

Spalten sind die Vereinigung aller Pfade über alle Zeilen, in der Reihenfolge des ersten Auftretens; ein Feld, das nur manche Datensätze tragen, bekommt also trotzdem eine Spalte und die anderen eine leere Zelle. Attribute landen in der Tiefe, in der sie auftreten, ein currency-Attribut an price wird also price.@currency, statt mit price zu kollidieren. Namensraumpräfixe bleiben wortwörtlich, Entities und CDATA werden aufgelöst, Blatttext wird getrimmt.

<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
Das Beispieldokument und die Spalten, die es erzeugt. Felder werden nur dann in Anführungszeichen gesetzt, wenn die RFC 4180 es verlangt.

Wiederholte Elemente, und was genau verloren geht

Trägt ein Datensatz dasselbe Blattelement mehr als einmal, werden die Werte mit einem senkrechten Strich zu einer Zelle verbunden und gezählt. Die Zelle wird weiterhin korrekt maskiert, ein Komma in einem dieser Werte zerlegt die Spalten also nicht. Was Sie verlieren, ist die Grenze: Ein Wert, der schon einen senkrechten Strich enthält, wird mehrdeutig, und das Trennzeichen ist nicht einstellbar.

Ein Fall, den das Verbinden nicht abdeckt: Hat das wiederholte Element eigene Kinder, schreibt jedes Auftreten in dieselben gepunkteten Spalten, das letzte gewinnt also und die früheren werden ohne Hinweis überschrieben. Bild-Erweiterungen in einem Sitemap-<url> und Positionszeilen mit je mehreren Feldern verhalten sich beide so. Holen Sie die mit XPath heraus, oder wandeln Sie nach JSON um.

Sitemaps und der SEO-Nutzen davon

Eine Sitemap ist eine Datensatzliste, sie wandelt sich also ohne Konfiguration: Die Wurzel ist <urlset>, das wiederholte Kind ist <url>, und Sie erhalten loc, lastmod, changefreq und priority als Spalten, eine Zeile je URL. Das reicht, um nach Änderungsdatum zu sortieren, gegen die Grenze von 50.000 URLs zu zählen oder die loc-Spalte neben einen Crawl-Export zu legen und die Seiten zu finden, die nur in einem von beiden stehen. Ein Sitemap-Index wandelt sich genauso.

Zwei Tatsachen für die Tabelle. Google erklärt, priority und changefreq zu ignorieren, diese Spalten prüfen also nur, was Ihr CMS ausgibt, und sonst nichts. Und Google benutzt lastmod nur, wenn es „durchgängig und überprüfbar genau“ ist; eine Spalte, in der alle Zeilen denselben Zeitstempel tragen oder ein Datum in der Zukunft stehen haben, wird Google wahrscheinlich verwerfen. Wenn Sie Regeln prüfen statt Daten herausholen wollen, nehmen Sie stattdessen den Sitemap-Validator, der die Obergrenzen, das changefreq-Vokabular und das W3C-Datetime-Format durchsetzt.

Das im Code tun

Dasselbe Flachlegen in den Sprachen, die XML verarbeiten. XML zu lesen ist die gefährliche Richtung, also benutzt jedes Beispiel die sichere Parserkonfiguration: externe Entities aus, DTD-Laden aus, kein Netz. Das CSV-Schreiben steht ausdrücklich da, denn am Maskieren brechen selbst gebaute Exporte meistens.

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

Jedes Beispiel schaltet etwas im Parser ab. Das unsichere Verhalten ist die Voreinstellung in Java und in Pythons Standardbibliothek, und Umwandlung ist genau der Zusammenhang, in dem die Datei von einem Lieferanten, einem Kunden oder aus einem Postfach kam und nicht von Ihnen.

Häufige Fragen

Wird mein XML hochgeladen, wenn ich es umwandle?

Nein. Scanner, Flachmacher und CSV-Schreiber sind JavaScript, das in einem Web Worker in diesem Tab läuft, und es gibt kein Backend, an das etwas gehen könnte. Öffnen Sie Ihr Netzwerkpanel und wandeln Sie etwas um: Die Seitenassets laden einmal, danach nichts.

Bei der Umwandlung zählt das mehr als beim Validieren, wegen der Art dieser Dateien. Niemand wandelt einen Tutorial-Schnipsel nach CSV. Menschen wandeln Kundenexporte, Auftragshistorien und Lieferantenpreislisten. Ihre Eingabe wird im localStorage dieses Browsers gehalten, damit ein Neuladen sie nicht verliert, und „Leeren“ entfernt sie.

Warum habe ich eine Zeile statt hunderter bekommen?

Fast immer, weil die Liste eine Ebene unter der Wurzel sitzt. Das Zeilenelement wird unter den direkten Kindern der Wurzel gewählt, <catalog><books><book/><book/></books></catalog> sieht also ein <books>-Kind, gibt eine Zeile und legt jedes <book> in dieselben Spalten flach, wobei das letzte gewinnt.

Entfernen Sie die Verpackung, damit das wiederholte Element ein direktes Kind ist, oder wandeln Sie nach JSON um. <book> in das Feld „Zeilenelement“ zu schreiben hilft nicht: Es meldet, dass unter der Wurzel kein <book> gefunden wurde, und das stimmt.

Was bedeutet der senkrechte Strich in dieser Zelle?

CSV kann kein Feld ausdrücken, das mehr als einmal auftritt, also werden mehrere Kopien desselben Blattelements mit einem senkrechten Strich verbunden, und das Panel meldet, wie oft das passiert ist. Die Zelle wird sauber maskiert, Kommata in diesen Werten zerlegen die Spalten also nicht. Der Verlust ist die Grenze: Ein Wert, der schon einen senkrechten Strich enthält, ist jetzt mehrdeutig.

Hat das wiederholte Element eigene Kinder, greift das Verbinden nicht. Jedes Auftreten schreibt in dieselben gepunkteten Spalten, und das letzte überschreibt den Rest. Holen Sie die mit XPath heraus, oder nehmen Sie JSON, wo Wiederholungen zu einem Array werden.

Wird sich das Ergebnis in Excel richtig öffnen?

Meist ja, mit zwei Vorbehalten, die dem Konverter angelastet werden. Excel unter Windows liest eine nackte UTF-8-Datei als System-Codepage, solange sie nicht mit einer Byte-Order-Mark beginnt, was Namen mit Akzenten in Zeichensalat verwandelt: Importieren Sie über Daten und dann Aus Text/CSV und stellen UTF-8 ein, statt doppelt zu klicken.

Excel formatiert auch Werte um, die es erkennt. Ein Produktcode 0012 verliert seine Nullen, 1-2 wird ein Datum, eine lange Kennung wird wissenschaftliche Notation. Markieren Sie diese Spalten beim Import als Text, und wenn Ihre Locale an Semikolons trennt, schalten Sie oben die Trennzeichen-Einstellung um.

Wie große Dateien kann ich umwandeln?

Bis zu 20 Millionen Zeichen, ungefähr 20 MB, ohne Konto, ohne Warteschlange und ohne Größenstufe, weil es keinen Server gibt, der eine abrechnen könnte. Das sichtbarste kostenlose XML-Werkzeug in diesem Feld begrenzt die Eingabe auf 512 KB und bittet Sie zu bestätigen, dass Ihre Daten auf seinen Servern gespeichert werden.

Die Grenze ist Speicher, nicht Politik: Alles bleibt in diesem Tab. Darunter sind die Kosten linear, 1 MB in etwa 110 Millisekunden und 10 MB in wenig mehr als einer Sekunde. Darüber verarbeiten Sie lokal im Strom mit iterparse, SAX oder xmlstarlet.

Soll ich nach CSV oder nach JSON umwandeln?

CSV, wenn das Ziel eine Tabelle, ein Massenimport, der Spalten erwartet, oder ein Mensch ist, der sortieren und filtern wird. JSON, wenn die Daten eine Struktur haben, die es wert ist, behalten zu werden.

Der Test geht schnell: Sehen Sie einen Datensatz an und fragen Sie, ob jedes Feld genau einmal vorkommt und einen einzigen Wert trägt. Wenn ja, ist CSV treu. Wiederholt sich etwas oder trägt Unterfelder, werden Sie diese Struktur später aus Trennzeichen in Zellen wieder aufbauen.

Verwandte Werkzeuge

Zum Weiterlesen