Convertitore da XML a CSV
Appiattisce in CSV e avvisa quando la struttura non ci sta.
Tutto viene eseguito in questa scheda. Nulla di ciò che incolli viene caricato, registrato o inviato da qualche parte. Apri il pannello di rete e verifica.
Incolla un documento XML fatto di un elenco di record e diventa una tabella separata da virgole che puoi aprire in Excel o caricare con pandas. L’elemento riga viene rilevato per te, i figli annidati diventano nomi di colonna puntati come address.city, e gli attributi diventano colonne con il prefisso @. Lo scanner, l’appiattitore e lo scrittore CSV girano in un Web Worker di questa scheda, quindi nulla viene caricato e nulla è chiuso dietro un account.
Ci si ricorre quando qualcosa a monte parla solo XML e qualcosa a valle parla solo righe: un feed di prodotti, un export di estratto conto, un report uscito da un vecchio ERP, una risposta di API che vuoi guardare prima di scriverci del codice contro. È anche il modo più rapido di trasformare una sitemap in una colonna di URL.
La differenza è che la conversione ti dice cosa non ha potuto rappresentare. CSV è un rettangolo e XML è un albero, quindi alcuni documenti si appiattiscono esattamente e altri perdono dati. Quando elementi ripetuti vengono ripiegati in una sola cella, il pannello lo dice e indica il conteggio; quando l’elemento riga è stato indovinato, nomina quello che ha scelto. Quasi tutti i convertitori ti consegnano un file e lasciano a te accorgertene.
CSV è un rettangolo e XML è un albero
La RFC 4180 mette il vincolo in una riga: «Ogni riga dovrebbe contenere lo stesso numero di campi in tutto il file.» XML non ha una regola così, e questo scarto è tutta la difficoltà. La conversione è fedele solo quando l’XML è già tabellare: un record che si ripete e i cui figli sono foglie a valore singolo.
Quando un record contiene un figlio ripetuto, un <order> che porta tre elementi <line>, un convertitore ha tre opzioni: esplodere in una riga per line duplicando le colonne del genitore, ripiegare le ripetizioni in una cella, oppure inventare colonne line.1.sku e line.2.sku il cui numero dipende dal record più largo. Tutte e tre perdono qualcosa, non ce n’è una quarta, e questo strumento prende la seconda e lo segnala.
- Si appiattisce esattamente: un elemento ripetuto sotto la radice, figli foglia, attributi sul record o sulle sue foglie.
- Si appiattisce con perdita: un record che contiene un figlio ripetuto. I valori vengono uniti e il conteggio segnalato.
- Non si appiattisce: contenuto misto, fratelli eterogenei, o un documento in cui l’annidamento è proprio il punto. Usa JSON.
Come vengono scelti l’elemento riga e le colonne
Lascia vuota la casella Elemento riga e il figlio elemento più frequente della radice diventa la riga, cosa giusta per quasi ogni input da XML a CSV. La decisione viene stampata anziché nascosta: «Le righe sono state prese dai 2 elementi <book> sotto <catalog>». Se l’ipotesi è sbagliata, scrivi tu il nome.
Vale la pena conoscere un limite prima di incollare. L’elemento riga deve essere figlio diretto della radice, quindi un documento che avvolge il suo elenco un livello più in basso, <catalog><books><book/><book/></books></catalog>, dà una sola riga per <books>. Togli quell’involucro, oppure converti in JSON, dove l’annidamento sopravvive.
Le colonne sono l’unione di ogni percorso su tutte le righe, nell’ordine in cui sono apparse, quindi un campo che solo alcuni record portano ottiene comunque una colonna e gli altri una cella vuota. Gli attributi si posano alla profondità in cui ricorrono, quindi un attributo currency su price diventa price.@currency invece di collidere con price. I prefissi di namespace restano letterali, entità e CDATA vengono risolti, il testo delle foglie viene ripulito dagli spazi.
<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.95Elementi ripetuti, ed esattamente cosa si perde
Quando un record porta lo stesso elemento foglia più di una volta, i valori vengono uniti con una barra verticale in un’unica cella e contati. La cella resta correttamente protetta, quindi una virgola dentro uno di quei valori non rompe le colonne. Quello che perdi è il confine: un valore che già contiene una barra verticale diventa ambiguo, e il separatore non è configurabile.
Un caso che l’unione non copre: quando l’elemento ripetuto ha figli propri, ogni occorrenza scrive nelle stesse colonne puntate, quindi l’ultima vince e le precedenti vengono sovrascritte senza una nota. Le estensioni immagine dentro un <url> di sitemap, e le righe d’ordine con più campi ciascuna, si comportano entrambe così. Tirale fuori con XPath, oppure converti in JSON.
Le sitemap, e l’uso SEO di tutto questo
Una sitemap è un elenco di record, quindi si converte senza configurazione: la radice è <urlset>, il figlio ripetuto è <url>, e ottieni loc, lastmod, changefreq e priority come colonne, una riga per URL. Basta per ordinare per data di modifica, contare rispetto al limite di 50.000 URL, o incollare la colonna loc accanto a un export di crawl per trovare le pagine presenti in uno e non nell’altro. Un indice di sitemap si converte allo stesso modo.
Due fatti da portare nel foglio di calcolo. Google dichiara di ignorare priority e changefreq, quindi quelle colonne controllano cosa emette il tuo CMS e nient’altro. E Google usa lastmod solo quando è «coerentemente e verificabilmente accurato», quindi una colonna in cui ogni riga condivide lo stesso timestamp, o porta una data futura, è una colonna che Google probabilmente scarterà. Per controllare le regole anziché estrarre, usa invece il validatore di sitemap, che applica i limiti, il vocabolario di changefreq e il formato W3C Datetime.
Farlo da codice
Lo stesso appiattimento nei linguaggi che consumano XML. Leggere XML è la direzione pericolosa, quindi ogni esempio usa la configurazione di parser sicura: entità esterne disattivate, caricamento delle DTD disattivato, nessuna rete. La scrittura del CSV è esplicita, perché è nel carattere di escape che gli export fatti a mano di solito si rompono.
// 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; ...'Ogni esempio disattiva qualcosa nel parser. Il comportamento non sicuro è quello predefinito in Java e nella libreria standard di Python, e la conversione è esattamente il contesto in cui il file è arrivato da un fornitore, da un cliente o da una casella di posta, e non da te.
Domande frequenti
Il mio XML viene caricato quando lo converto?
No. Lo scanner, l’appiattitore e lo scrittore CSV sono JavaScript che gira in un Web Worker di questa scheda, e non c’è alcun back end a cui mandare qualcosa. Apri il pannello di rete e converti qualcosa: le risorse della pagina si caricano una volta, e poi più nulla.
Per la conversione conta più che per la convalida, per la natura di questi file. Nessuno converte in CSV un frammento da tutorial. Le persone convertono export di clienti, storici di ordini e listini di fornitori. Il tuo input resta nel localStorage di questo browser perché un aggiornamento non lo perda, e Pulisci lo rimuove.
Perché ho ottenuto una riga invece di centinaia?
Quasi sempre perché l’elenco sta un livello sotto la radice. L’elemento riga viene scelto fra i figli diretti della radice, quindi <catalog><books><book/><book/></books></catalog> vede un figlio <books>, dà una riga, e appiattisce ogni <book> nelle stesse colonne con l’ultimo che vince.
Rimuovi l’involucro così che l’elemento ripetuto sia figlio diretto, oppure converti in JSON. Scrivere <book> nella casella Elemento riga non aiuta: segnala che sotto la radice non è stato trovato alcun <book>, e questo è vero.
Che cosa significa la barra verticale in quella cella?
Il CSV non può esprimere un campo che ricorre più di una volta, quindi più copie dello stesso elemento foglia vengono unite con una barra verticale e il pannello segnala quante volte è accaduto. La cella è protetta come si deve, quindi le virgole dentro quei valori non rompono le colonne. La perdita è il confine: un valore che già contiene una barra verticale ora è ambiguo.
Quando l’elemento ripetuto ha figli propri l’unione non si applica. Ogni occorrenza scrive nelle stesse colonne puntate e l’ultima sovrascrive le altre. Estraili con XPath, o usa JSON, dove le ripetizioni diventano un array.
Il risultato si aprirà bene in Excel?
Di solito sì, con due riserve di cui si dà la colpa al convertitore. Excel su Windows legge un file UTF-8 nudo come la code page di sistema a meno che inizi con un byte order mark, cosa che trasforma i nomi accentati in caratteri illeggibili: importa da Dati e poi Da testo/CSV impostando UTF-8 anziché fare doppio clic.
Excel riformatta anche i valori che riconosce. Un codice prodotto 0012 perde gli zeri, 1-2 diventa una data, un identificatore lungo diventa notazione scientifica. Segna quelle colonne come Testo all’importazione, e se la tua lingua separa con il punto e virgola, cambia il controllo Delimitatore qui sopra.
Quanto grande può essere il file che converto?
Fino a 20 milioni di caratteri, circa 20 MB, senza account, senza coda e senza fasce di dimensione, perché non c’è un server a contarle. Lo strumento XML gratuito più visibile in questo ambito limita l’input a 512 KB e ti chiede di confermare che i tuoi dati sono conservati sui suoi server.
Il limite è di memoria e non di politica: tutto resta in questa scheda. Sotto quella soglia il costo è lineare, 1 MB in circa 110 millisecondi e 10 MB in poco più di un secondo. Sopra, elabora in streaming in locale con iterparse, SAX o xmlstarlet.
Conviene convertire in CSV o in JSON?
CSV quando la destinazione è un foglio di calcolo, un’importazione di massa che si aspetta colonne, o una persona che ordinerà e filtrerà. JSON quando i dati hanno una struttura che vale la pena conservare.
La prova è rapida: guarda un record e chiediti se ogni campo compare esattamente una volta e porta un singolo valore. Se sì, il CSV è fedele. Se qualcosa si ripete o porta sotto-campi, ti ritroverai a ricostruire quella struttura più tardi da separatori dentro le celle.