XPath を使って XML からデータを抽出する WSH

業務で巨大 XML ファイルから手作業で数十項目を抽出することがたまにあるので作りました。

環境
----

- Windows 7 Home Premium

参考
----

- [XMLテクニック集（5）：XPath関数で部分テキストを出力する (1/3) - ＠IT](http://www.atmarkit.co.jp/ait/articles/0309/09/news003.html)
- [XML Path Language (XPath)](http://www.y-adagio.com/public/standards/tr_xpath/xpath.htm)
- [Windows 8 以降の MSXML2.DOMDocument の使用方法 – Microsoft SQL Server Japan Support Team Blog](https://blogs.msdn.microsoft.com/jpsql/2016/11/25/howtouse_msxml2domdocument/)

サンプルデータ
----

```xml:examples-persons.xml
<?xml version="1.0" encoding="UTF-8"?>
<persons>
  <person>
    <name>Yamada One</name>
    <age>10</age>
    <favorite class="food">pizza</favorite>
  </person>
  <person>
    <name>Yamada Two</name>
    <age>20</age>
    <favorite class="sports">ski</favorite>
  </person>
  <person>
    <name>Takahashi One</name>
    <age>30</age>
    <favorite class="music">rap</favorite>
  </person>
  <person>
    <name>Takahashi Two</name>
    <age>40</age>
    <favorite class="food">tomato</favorite>
  </person>
  <person>
    <name>Numata One</name>
    <age>100</age>
    <favorite class="food">pizza</favorite>
  </person>
  <person>
    <name>Numata Two</name>
    <age>110</age>
    <favorite class="sports">boxing</favorite>
  </person>
  <archived>
    <person>
      <name>Yamada Three</name>
      <age>21</age>
      <favorite class="food">pizza</favorite>
    </person>
  </archived>
</persons>
```

スクリプト
----

```javascript:extract-xml.js
// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
// 抽出設定

var extractionRules = {
  AllPersons01:
    "//person",
  AllPersons02:
    "/persons/person",
  PizzaLover:
    "/persons/person[favorite/@class = 'food'][favorite='pizza']",
  Yamada:
    "/persons/person[starts-with(name, 'Yamada ')]",
  NameContainsMa:
    "/persons/person[contains(name, 'ma')]",
  FavoriteAttributeContainsO:
    "/persons/person[substring(favorite/@class, 3, 1) = 'o']",
  AgeLessThan100:
    "/persons/person[age < 100]",
  AgeDigit3:
    "/persons/person[string-length(age) = 3]"
};

// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
// functions

var Extractor = (function(rules) {

  /**
   * DOM ドキュメントオブジェクトの作成。
   *
   * @private
   * @method
   * @param  {string}        path - XML ファイルのパス。
   * @return {ActiveXObject}      - 指定 XML ファイルを読み込んだ DOM ドキュメントオブジェクト。
   */
  function createDomDocument(path) {
    var dom;

    for (var i = 6; i > 0; i--) {
      try {
        dom = new ActiveXObject('MSXML2.DOMDocument.' + i + '.0');
      }
      catch (err) {}
    }

    dom.async            = false;
    dom.resolveExternals = false;
    dom.validateOnParse  = false;
    dom.setProperty("SelectionLanguage", "XPath");

    dom.load(path);
    return dom;
  }

  /**
   * 出力先パスの作成。
   * 処理対象のファイルのパスに対して拡張子があればその直前に接尾辞を、なければ最後に接尾辞を付ける。
   *
   * @private
   * @method
   * @param  {string} srcPath   - 処理対象の XML ファイルのパス。
   * @param  {string} srcSuffix - 移動先ファイル名に付ける接尾辞の素材。
   * @return {string}           - 出力先パス。
   */
  function createDestPath(srcPath, srcSuffix) {
    var suffix = '(extracted-' + srcSuffix + ')';
    var destPath = srcPath.replace(/([^\\\/]+)(\.[^\.]+)$/, '$1' + suffix + '$2');

    if (destPath === srcPath) {
      destPath = srcPath + suffix;
    }

    return destPath;
  }

  /**
   * ファイル出力で用いるストリームオブジェクトの作成。
   *
   * @private
   * @method
   * @return {ActiveXObject} - 共通設定済みの ADODB.Stream オブジェクト。
   */
  function createFileStream() {
    var stream = new ActiveXObject("ADODB.Stream");
    stream.Type = 2; // text
    stream.Charset = 'UTF-8';

    return stream;
  }

  /**
   * 指定のルールに基いて DOM からノードを抽出し、文字列として連結して返す。
   *
   * @private
   * @method
   * @param  {string} dom  - XML ファイル全体を表す DOM ドキュメントオブジェクト。
   * @param  {string} rule - 抽出ルール。 (XPath)
   * @return {string}      - 抽出によって生成された文字列。抽出できなかった場合は空文字列。
   */
  function extractNodeText(dom, rule) {
    var text = "";
    var nodes;

    try {
      nodes = dom.selectNodes(rule);

      for (var i = 0; i < nodes.length; i++) {
        if (0 < i) {
          text += "\n";
        }

        text += nodes[i].xml;
      }
    }
    catch (err) {
      text = "";
    }

    return text;
  }

  /**
   * 文字列をファイルに出力する。
   *
   * @private
   * @method
   * @param  {string} text      - XML から抽出された文字列。
   * @param  {string} srcPath   - 処理対象の XML ファイルのパス。
   * @param  {string} srcSuffix - 移動先ファイル名に付ける接尾辞の素材。
   * @return {void}
   */
  function outputText(text, srcPath, srcSuffix) {
    var stream;
    var destPath = createDestPath(srcPath, srcSuffix);

    try {
      stream = createFileStream();
      stream.Open();

      // 改行付き
      stream.WriteText(text, 1);

      // 上書き
      stream.SaveToFile(destPath, 2);
    }
    finally {
      stream.Close();
    }
  }

  return {
    /**
     * 抽出処理の実行。
     *
     * @public
     * @method
     * @param  {string} srcPath   - 処理対象の XML ファイルのパス。
     * @return {void}
     */
    run: function(srcPath) {
      var dom      = createDomDocument(srcPath);
      var text = "";

      for (var key in rules) {
        if (!rules.hasOwnProperty(key)) {
          continue;
        }

        text = extractNodeText(dom, rules[key]);

        if (0 < text.length) {
          outputText(text, srcPath, key);
        }
      }
    }

  };
}(extractionRules));

// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
// main

// コマンド引数から抽出対象の XML ファイルのパスを得る
var pathArgs = WScript.Arguments;

// 各ファイルに対して抽出処理を実行
for (var i = 0; i < pathArgs.length; i++) {
  Extractor.run(pathArgs(i));
}
```

説明
----

### 実行手順

1. 上記スクリプトを拡張子 `js` 付きで Windows 環境に保存。
1. 冒頭の `extractionRules` に、連想配列の形で抽出用の値を設定する。
  - キーが出力時の接尾辞、値が XPath 。
1. ドラッグアンドドロップ等で XML ファイルのパスを実行時引数として渡す。 (複数可)

### 仕様

- XPath が正常かつ抽出結果が 1 件以上ある場合、処理対象の XML ファイルと同じ場所に接尾辞を付けて出力。
- 出力内容は XPath に引っかかったものを改行付きで単純に連結する。
  - `//` を使ったものなど、 XPath のつくりによっては深さが異なる要素が同じ深さで出力されてしまうので注意。

出力例
----

### AllPersons01

`person` タグの要素をすべて抽出する。

深さを無視して抽出するので `/persons/archived` 内の要素も対象かつ、深さ一定。

#### XPath

`//person`

#### 出力内容

```
<person>
	<name>Yamada One</name>
	<age>10</age>
	<favorite class="food">pizza</favorite>
</person>
<person>
	<name>Yamada Two</name>
	<age>20</age>
	<favorite class="sports">ski</favorite>
</person>
<person>
	<name>Takahashi One</name>
	<age>30</age>
	<favorite class="music">rap</favorite>
</person>
<person>
	<name>Takahashi Two</name>
	<age>40</age>
	<favorite class="food">tomato</favorite>
</person>
<person>
	<name>Numata One</name>
	<age>100</age>
	<favorite class="food">pizza</favorite>
</person>
<person>
	<name>Numata Two</name>
	<age>110</age>
	<favorite class="sports">boxing</favorite>
</person>
<person>
	<name>Yamada Three</name>
	<age>21</age>
	<favorite class="food">pizza</favorite>
</person>
```

### AllPersons02

`/persons/person` 要素を抽出。

パスの対象外なので `/persons/archived/person` の内容は抽出されない。

#### XPath

`/persons/person`

#### 出力内容

```
<person>
	<name>Yamada One</name>
	<age>10</age>
	<favorite class="food">pizza</favorite>
</person>
<person>
	<name>Yamada Two</name>
	<age>20</age>
	<favorite class="sports">ski</favorite>
</person>
<person>
	<name>Takahashi One</name>
	<age>30</age>
	<favorite class="music">rap</favorite>
</person>
<person>
	<name>Takahashi Two</name>
	<age>40</age>
	<favorite class="food">tomato</favorite>
</person>
<person>
	<name>Numata One</name>
	<age>100</age>
	<favorite class="food">pizza</favorite>
</person>
<person>
	<name>Numata Two</name>
	<age>110</age>
	<favorite class="sports">boxing</favorite>
</person>
```

### PizzaLover

`/persons/person` 要素のうち `favorite` タグの属性値 `food` が `pizza` のものを抽出。

#### XPath

`/persons/person[favorite/@class = 'food'][favorite='pizza']`

#### 出力内容

```
<person>
	<name>Yamada One</name>
	<age>10</age>
	<favorite class="food">pizza</favorite>
</person>
<person>
	<name>Numata One</name>
	<age>100</age>
	<favorite class="food">pizza</favorite>
</person>
```

### Yamada

`name` タグの値が `Yamada` で始まる要素を抽出。

#### XPath

`/persons/person[starts-with(name, 'Yamada ')]`

#### 出力内容

```
<person>
	<name>Yamada One</name>
	<age>10</age>
	<favorite class="food">pizza</favorite>
</person>
<person>
	<name>Yamada Two</name>
	<age>20</age>
	<favorite class="sports">ski</favorite>
</person>
```

### NameContainsMa

`name` タグの値に `ma` が含まれる要素を抽出。

#### XPath

`/persons/person[contains(name, 'ma')]`

#### 出力内容

```
<person>
	<name>Yamada One</name>
	<age>10</age>
	<favorite class="food">pizza</favorite>
</person>
<person>
	<name>Yamada Two</name>
	<age>20</age>
	<favorite class="sports">ski</favorite>
</person>
<person>
	<name>Numata One</name>
	<age>100</age>
	<favorite class="food">pizza</favorite>
</person>
<person>
	<name>Numata Two</name>
	<age>110</age>
	<favorite class="sports">boxing</favorite>
</person>
```

### FavoriteAttributeContainsO

`/persons/person` 要素のうち `favorite` タグの属性値の 3 文字目が `o` のものを抽出。

#### XPath

`/persons/person[substring(favorite/@class, 3, 1) = 'o']`

#### 出力内容

```
<person>
	<name>Yamada One</name>
	<age>10</age>
	<favorite class="food">pizza</favorite>
</person>
<person>
	<name>Yamada Two</name>
	<age>20</age>
	<favorite class="sports">ski</favorite>
</person>
<person>
	<name>Takahashi Two</name>
	<age>40</age>
	<favorite class="food">tomato</favorite>
</person>
<person>
	<name>Numata One</name>
	<age>100</age>
	<favorite class="food">pizza</favorite>
</person>
<person>
	<name>Numata Two</name>
	<age>110</age>
	<favorite class="sports">boxing</favorite>
</person>
```

### AgeLessThan100

`/persons/person` 要素のうち `age` が 100 未満のものを抽出。

#### XPath

`/persons/person[age < 100]`

#### 出力内容

```
<person>
	<name>Yamada One</name>
	<age>10</age>
	<favorite class="food">pizza</favorite>
</person>
<person>
	<name>Yamada Two</name>
	<age>20</age>
	<favorite class="sports">ski</favorite>
</person>
<person>
	<name>Takahashi One</name>
	<age>30</age>
	<favorite class="music">rap</favorite>
</person>
<person>
	<name>Takahashi Two</name>
	<age>40</age>
	<favorite class="food">tomato</favorite>
</person>
```

### AgeDigit3

`/persons/person` 要素のうち `age` の文字列長が 3 のものを抽出。

#### XPath

`/persons/person[string-length(age) = 3]`

#### 出力内容

```
<person>
	<name>Numata One</name>
	<age>100</age>
	<favorite class="food">pizza</favorite>
</person>
<person>
	<name>Numata Two</name>
	<age>110</age>
	<favorite class="sports">boxing</favorite>
</person>
```