XML から CSV への変換

CSV に展開。構造が収まらないときは知らせます。

入力
出力
待機中文書を貼り付けると検査します。入力中にそのまま検証されます。

すべてこのタブ内で実行されます。貼り付けた内容がアップロード・記録・送信されることはありません。 ネットワークパネルを開いて確認する.

レコードの並びからなる XML 文書を貼り付けると、Excel で開けて pandas でも読み込めるカンマ区切りの表になります。行要素は自動で見つけ、入れ子の子は address.city のようなドットつきの列名になり、属性は @ を頭に付けた列になります。スキャナー、平坦化、CSV の書き出しはこのタブの Web Worker で動くので、何もアップロードされず、何もアカウントの向こうに囲われていません。

上流が XML しか話さず、下流が行しか話さないときに使ってください。商品フィード、銀行明細の書き出し、古い ERP から出たレポート、コードを書く前に目で見ておきたい API 応答。サイトマップを URL の列にするいちばん速い方法でもあります。

違うのは、表現できなかったものを変換側から言うところです。CSV は長方形で XML は木なので、ぴったり平坦化できる文書もあれば、データを失う文書もあります。繰り返し要素を 1 つのセルにまとめたときはパネルがそう告げて件数も出し、行要素を推測したときは何を選んだかを名指します。多くのコンバーターはファイルを渡して、気づくかどうかはあなた任せです。

CSV は長方形で、XML は木

RFC 4180 は制約を 1 行で書いています。「各行は、ファイル全体を通じて同じ数のフィールドを含むべきである」。XML にそんな規則はなく、この食い違いが難しさのすべてです。変換が忠実なのは、XML がすでに表になっているときだけ。つまり、繰り返すレコードが 1 つあり、その子が単一の値を持つ葉であるときです。

レコードが繰り返しの子を含むとき、たとえば <order> が 3 つの <line> を抱えているとき、コンバーターには 3 つの選択肢があります。line ごとに 1 行へ展開して親の列を複製する、繰り返しを 1 つのセルにまとめる、あるいは line.1.sku、line.2.sku のような列を作って、その本数をいちばん幅の広いレコードに合わせる。3 つともどこかを失い、4 つめはありません。このツールは 2 つめを選び、そしてそれを報告します。

  • ぴったり平坦化できる:ルートの下に繰り返し要素が 1 つ、その子は葉、属性はレコードかその葉に付いている。
  • 失いながら平坦化する:レコードが繰り返しの子を含む。値は連結され、件数が報告されます。
  • 平坦化できない:混合内容、性質の違う兄弟、あるいは入れ子そのものが要点の文書。JSON を使ってください。

行要素と列はどう選ばれるか

「行要素」の欄を空にしておけば、ルートの子要素のうちもっとも多いものが行になります。XML から CSV への入力のほぼすべてでこれが正解です。その判断は隠さず印字されます。「行は <catalog> の下にある 2 つの <book> 要素から取りました」。推測が外れていたら、自分で名前を書いてください。

貼り付ける前に知っておく価値のある限界が 1 つ。行要素はルートの直接の子でなければなりません。ですから、<catalog><books><book/><book/></books></catalog> のようにリストを 1 階層深く包んだ文書は、<books> に対して 1 行を返します。その包みを外すか、入れ子が生き残る JSON に変換してください。

列は全行にわたるすべての経路の和集合で、最初に見えた順に並びます。ですから一部のレコードにしかないフィールドにも列ができ、ほかは空のセルになります。属性は現れた深さに置かれるので、price の currency 属性は price と衝突せず price.@currency になります。名前空間の接頭辞はそのまま残り、実体と CDATA は解決され、葉のテキストは前後の空白を落とします。

<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
サンプル文書と、それが生み出す列。フィールドに引用符が付くのは、RFC 4180 が求めるときだけです。

繰り返し要素と、正確に何が失われるか

レコードが同じ葉要素を 2 回以上持つとき、値は縦棒で連結されて 1 つのセルに入り、件数が数えられます。セルのエスケープは正しく行われるので、その値の中のカンマで列が崩れることはありません。失われるのは境界です。もともと縦棒を含む値は曖昧になり、区切り文字は設定できません。

連結では片づかない場合が 1 つ。繰り返される要素が自分の子を持つとき、出現のたびに同じドットつきの列へ書き込むので、最後のものが勝ち、前のものは注記もなく上書きされます。サイトマップの <url> の中の画像拡張も、それぞれ複数のフィールドを持つ明細行も、どちらもこう振る舞います。XPath で抜き出すか、JSON に変換してください。

サイトマップと、これの SEO 用途

サイトマップはレコードの並びなので、設定なしで変換できます。ルートは <urlset>、繰り返しの子は <url>、そして loc、lastmod、changefreq、priority が列になり、URL ごとに 1 行です。更新日で並べ替える、5 万 URL の上限に対して数える、クロール結果の隣に loc 列を貼って片方にしかないページを見つける、といったことはこれで足ります。サイトマップ索引も同じように変換できます。

表計算に持っていく前に覚えておく事実が 2 つ。Google は priority と changefreq を無視すると明言しているので、それらの列は自社 CMS が何を出しているかを点検するだけのものです。そして Google が lastmod を使うのは「一貫して、検証可能に正確」なときだけなので、全行が同じタイムスタンプを共有する列や、未来の日付が入った列は、Google が捨てる見込みが高い列です。抽出ではなく規則の点検をしたいなら、上限、changefreq の語彙、W3C Datetime 形式を強制するサイトマップバリデーターを使ってください。

コードで行う

同じ平坦化を、XML を扱う各言語で。危険なのは XML を読む方向なので、どのサンプルも安全なパーサー設定を使います。外部実体は無効、DTD の読み込みも無効、ネットワークなし。CSV の書き出しは明示的に書いています。手作りの書き出しがよく壊れるのはエスケープのところだからです。

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

どのサンプルもパーサーの何かを無効にしています。危険な側の振る舞いが既定なのは Java と Python の標準ライブラリであり、そして変換とは、ファイルが自分からではなく取引先や顧客や受信箱から届いた、まさにその場面です。

よくある質問

変換するとき、私の XML はアップロードされますか。

いいえ。スキャナー、平坦化、CSV の書き出しは、このタブの Web Worker で動く JavaScript であり、送る先のバックエンドもありません。ネットワークパネルを開いて何か変換してみてください。ページのアセットが一度読み込まれ、そのあとは何もありません。

これは検証より変換のときに重要です。ファイルの性質が違うからです。チュートリアルの断片を CSV にする人はいません。人が変換するのは顧客の書き出し、注文履歴、仕入先の価格表です。入力は、再読み込みで失われないようこのブラウザーの localStorage に保存し、「クリア」で消えます。

何百行になるはずが 1 行しか出ないのはなぜですか。

ほとんどの場合、リストがルートより 1 階層下にあるからです。行要素はルートの直接の子から選ぶので、<catalog><books><book/><book/></books></catalog> では <books> という子を 1 つ見つけて 1 行を返し、すべての <book> を同じ列に平坦化して最後のものを勝たせます。

包みを取り除いて繰り返し要素をルートの直接の子にするか、JSON に変換してください。「行要素」の欄に <book> と書いても助けになりません。ルートの下に <book> は見つからなかったと報告するだけで、それは事実です。

あのセルの縦棒は何を意味しますか。

CSV は 2 回以上現れるフィールドを表現できないので、同じ葉要素の複数の写しを縦棒で連結し、それが何度起きたかをパネルが報告します。セルは正しくエスケープされるので、その値の中のカンマで列が崩れることはありません。失われるのは境界です。もともと縦棒を含む値は、いまや曖昧になります。

繰り返される要素が自分の子を持つ場合、この連結は行われません。出現ごとに同じドットつきの列に書き込み、最後のものが残りを上書きします。そうしたものは XPath で抜き出すか、繰り返しが配列になる JSON を使ってください。

結果は Excel でちゃんと開きますか。

たいていは開きます。ただしコンバーターのせいにされがちな注意点が 2 つ。Windows の Excel は、バイト順マークで始まらない素の UTF-8 ファイルをシステムのコードページとして読むので、アクセント付きの名前が文字化けします。ダブルクリックではなく、「データ」から「テキストまたは CSV から」を選び、UTF-8 を指定して取り込んでください。

Excel は見覚えのある値を勝手に書き換えもします。商品コード 0012 はゼロを失い、1-2 は日付になり、長い識別子は指数表記になります。取り込み時にそれらの列を「テキスト」に指定してください。お使いのロケールがセミコロンで区切るなら、上の区切り文字の設定を切り替えてください。

どれくらい大きいファイルを変換できますか。

2000 万文字まで、おおよそ 20 MB までです。アカウントも待ち行列もサイズの段階もありません。計測するサーバーがそもそもないからです。この分野でいちばん目につく無料の XML ツールは入力を 512 KB に制限し、データが自社サーバーに保存されることの確認を求めます。

限界は方針ではなくメモリです。すべてがこのタブの中にあります。その下なら費用は線形で、1 MB はおよそ 110 ミリ秒、10 MB は 1 秒とすこしです。それを超えるなら、iterparse や SAX、xmlstarlet でローカルにストリーム処理してください。

CSV に変換すべきですか、JSON に変換すべきですか。

行き先が表計算、列を期待する一括取り込み、あるいは並べ替えと絞り込みをする人間なら CSV。データに保つ価値のある構造があるなら JSON です。

判定は手早くできます。レコードを 1 つ見て、どのフィールドもちょうど 1 回だけ現れ、1 つの値を持っているかを問うてください。そうなら CSV は忠実です。どこかが繰り返したり下位フィールドを持っていたりするなら、あとでセルの中の区切り文字からその構造を組み直すことになります。

関連ツール

関連する解説