【PowerShell】複数のXMLファイルからデータを自動取得する

# はじめに
客先で「**100件以上のPDFデータを取得してExcelにまとめたい**。1件ずつ手入力するのは時間がかかるので、何か良い方法はないですか？」と聞かれたので、PowerShellを使用して自動でデータを取得するようなスクリプトを作成しました。

PowerShellについて説明した記事は[こちら](https://qiita.com/k12da/items/7a0ed02caa77b4c329e3)です。

# 要件
業務でリース返却対象PCのデータ消去時のログをクラウド上で管理している。
データ消去ソフトを使用した時に、**XMLファイルとPDFファイル**が同時に生成される。

1. 上記いずれかのファイルから、機器の型番やシリアルなどの情報を取得する
2. 機器に内蔵されるHDDやSSDなどのディスクの型番やシリアルなども取得すること
3. 取得した情報は、Excelのシートに出力する

※サーバーの場合は、本体1台に複数のディスクが含まれている。

## XMLとは？
[XML（eXtensible Markup Language)](https://www.cybertech.co.jp/xml/contents/xmlxmldb/serial/_xmlbeginner1.php)のことで、日本語に訳すと「拡張可能なマークアップ言語」と訳されます。

データに意味を持たせるタグ（<book>、<author>など）でデータを囲み、データの構造を明確にします。以下はその例です。

```xml
<?xml version="1.0" encoding="UTF-8"?>
<library>
  <book>
    <title>吾輩は猫である</title>
    <author>夏目漱石</author>
  </book>
  <book>
    <title>走れメロス</title>
    <author>太宰治</author>
  </book>
</library>
```

### HTMLとの違い
大きな違いとしては、**タグを自由に定義できるかどうか**だと思います。
違いを以下の表にまとめました。

|特徴|HTML|XML|
|---|---|---|
|**タグ**|あらかじめ定義されたタグを使用|自由にタグを定義できる|
|**目的**|表示|データの構造化|
|**文法規則**|比較的ゆるい|厳格なところがある|
|**用途**|Webページ作成|データ交換、設定ファイル、など|

# 実現したこと
**生成されたXMLファイルから機器情報を取得し、エクセルに出力**
具体的な処理内容については、[実装したコード](#実装したコード)と合わせて記述しています

## 没案
PythonからPDFのテキストを抽出することも考えたのですが、客先の都合で社用PCへのPythonのインストールから厳しそうだったので断念しました...

> 参考記事：[PythonでPDFをテキストに変換し、テキストを抽出](https://qiita.com/iceblue/items/8ceb5674337f72fe5954)

:::note warn
業務で使用するPCに無断でアプリをインストールするのは禁止されています。
事前に上長へ確認しましょう。
> [アプリの勝手なインストールが禁止されている理由とは？](https://www.dos-osaka.co.jp/ss1/ss1lab/2023/09/trend-m-instrestrict.html)
:::

# 実装したコード
## 処理の大まかな流れ
1. 文字入力を行うボックスを開き、社員番号を入力する
2. フォルダを開くダイアログから、XMLファイルが保存されているフォルダを選択して開く
3. 指定されたフォルダの`.xml`の拡張子を持つファイルを全て取得する
4. 機器本体の[ノード](#ノードとは)を取得する
5. ディスクのノードを取得する
6. 抽出した情報を[ハッシュテーブル](#ハッシュテーブルとは)に格納する
7. エクセルを起動し、ブックを開く
8. 新しいシートを作成する
9. ヘッダーを作成する
10. ハッシュテーブルの値を指定したセルに表示する
11. マクロを実行（プラスαの実装）
12. No.の列を基準に昇順ソートする（プラスαの実装）

## PowerShell
```getXmlData.ps1
[Void][System.Reflection.Assembly]::Load("Microsoft.VisualBasic, Version=8.0.0.0, Culture=Neutral, PublicKeyToken=b03f5f7f11d50a3a")
Add-Type -AssemblyName System.Windows.Forms

# 1. 文字入力を行うボックスを開き、社員番号を入力する
$employeeNumber = [Microsoft.VisualBasic.Interaction]::InputBox("社員番号を入力してください。", "社員番号の入力")

If($employeeNumber -eq "") {
  Write-Output "入力キャンセル"
  exit
} else {
  Write-Output "社員番号： $employeeNumber"
}

# 2. フォルダを開くダイアログから、XMLファイルが保存されているフォルダを選択して開く
$dialog = New-Object System.Windows.Forms.OpenFileDialog
$dialog.InitialDirectory = "C:\"
$dialog.Title ="フォルダを選択してください"
$dialog.ValidateNames = 0
$dialog.CheckFileExists = 0
$dialog.CheckPathExists = 1

$dialog.FileName = "フォルダを選択"

if($dialog.ShowDialog() -eq [System.Windows.Forms.DialogResult]::OK) {
  $selectedPath = Split-Path -Parent $dialog.FileName
} else {
  Write-Output "フォルダ選択がキャンセルされました"
  exit
}

$hashTable = @()
$otherHashTable = @()

# 3. 指定されたフォルダの`.xml`の拡張子を持つファイルを全て取得する
Get-ChildItem -Path $selectedPath -Filter *.xml | ForEachObject {
  $xmlFile = $_.FullName
  $xmlFileName = Split-Path $xmlFile -leaf
  $splitPos = $xmlFileName.IndexOf('_')

  # ファイル名からNo.の値を生成する
  if($splitPos -ge 0) {
    $fileNumberStr = $xmlFileName.Substring(0, $splitPos)
    $fileNumber = [int]$fileNumberStr
  } else {
    $fileNumber = null
    Write-Output "アンダーバーが見つかりません"
  }

  # XMLファイルの内容を読み取り、XMLオブジェクトに変換する
  $content = Get-Content $xmlFile
  $xml = [xml]$content

  # 4. XMLファイルから機器本体のノードを取得する
  $hardwareNodeVendor = "/root/report/abc/abc_report/entries[@name='system']/entry[@name='manufacturer'][@type='string']"
  $hardwareNodeModel = "/root/report/abc/abc_report/entries[@name='system']/entry[@name='model'][@type='string']"
  $hardwareNodeSerial = "/root/report/abc/abc_report/entries[@name='system']/entry[@name='serial'][@type='string']"

  $hardwareVendor = $xml.SelectSingleNode($hardwareNodeVendor)."#text"
  $hardwareModel = $xml.SelectSingleNode($hardwareNodeModel)."#text"
  $hardwareSerial = $xml.SelectSingleNode($hardwareNodeSerial)."#text"

  # 5. ディスクのノードを取得する
  $DiskNodeEntries = $xml.SelectNodes("/root/report/abc/abc_report/entries[@name='erasures']/entries[@name='target']")

  # 各ディスクの特定の情報を抽出する
  foreach($item in $DiskNodeEntries) {
    $DiskVendor = $item.SelectSingleNode("entry[@name='vendor'][@type='string']")."#text"
    $DiskModel = $item.SelectSingleNode("entry[@name='model'][@type='string']")."#text"
    $DiskSerial = $item.SelectSingleNode("entry[@name='serial'][@type='string']")."#text"
    $ByteCapacity = $item.SelectSingleNode("entry[@name='capacity'][@type='uint']")."#text"

     # バイト（Byte）→ ギガバイト（GB）変換する
     $Capacity = [Math]::Round($ByteCapacity / 1e9)

     # 6. 抽出した情報をハッシュテーブルに格納する
     $hashTable += @{
       "No" = $fileNumber
       "FileName" = $xmlFileName
       "HardwareVendor" = $HardwareVendor
       "HardwareModel" = $HardwareModel
       "HardwareSerial" = $HardwareSerial
       "DiskVendor" = $DiskVendor
       "DiskModel" = $DiskModel
       "DiskSerial" = $DiskSerial
       "Capacity" = $Capacity
       "StartTime" = $StartTime
       "EndTime" = $EndTime
     }
  }

  $otherEntries = $eml.SelectNodes("/root/report/abc/abc_report/entries[@name='erasures']")

  foreach($item in $otherEntries) {
    $StartTime = $item.SelectSingleNode("entry[@name='start_time'][@type='string']")."#text"
    $EndTime = $item.SelectSingleNode("entry[@name='end_time'][@type='string']")."#text"

    # 消去の開始時間と終了時間を別のハッシュテーブルに格納する
    $otherHashTable += @{
      "StartTime" = $StartTme
      "EndTime" = $EndTime
    }
  }
}

# 7. エクセルを起動し、ブックを開く
$excel = NewObject -ComObject Excel.Application
$excel.Visble = $true
$excelPath = "C\Users\employeeNumber\***\***\test.xlsm"
$workbook = $excel.WorkBooks.Open($excelPath)

# 8. 新しいシートを作成する
$workbook = $excel.ActiveWorkBook
$Sheet = $workbook.Sheets($workbook.Sheets.Count)
$workbook.WorkSheets.Add([System.Reflection.Missing]::Value, $Sheet)
$workSheet = $workbook.Worksheets.Item($workbook.Sheets.Count)

# 9. ヘッダーを作成する
$headers = @("No.", "ファイル名", "メーカー名(本体)", "型番（本体）", "シリアル（本体）",  "メーカー名(ディスク)", "型番（ディスク）", "シリアル（ディスク）", "容量（GB）", "消去開始日時", "消去終了日時")

for($i=0; $i -lt $headers.Length; $i++) {
  $workSheet.Cells.Item(1, $i+1) = $headers[$i]
}

# ハッシュテーブルをマージする
$hashTable = $hashTable * $otherHashTable

# 初期化（2行目からハッシュテーブルの値を表示する）
$row = 2

# 10. ハッシュテーブルの値を指定したセルに表示する
foreach($item in $hashTable) {
  $workSheet.Cells.Item($row, 1) = $item.No
  $workSheet.Cells.Item($row, 1) = $item.FileName
  $workSheet.Cells.Item($row, 1) = $item.HardwareVendor
  $workSheet.Cells.Item($row, 1) = $item.HardwareModel
  $workSheet.Cells.Item($row, 1) = $item.HardwareSerial
  $workSheet.Cells.Item($row, 1) = $item.DiskVendor
  $workSheet.Cells.Item($row, 1) = $item.DiskModel
  $workSheet.Cells.Item($row, 1) = $item.DiskSerial
  $workSheet.Cells.Item($row, 1) = $item.Capacity
  $workSheet.Cells.Item($row, 1) = $item.StartTime
  $workSheet.Cells.Item($row, 1) = $item.EndTime
  
  $row++
}

# 列の自動調整
$workSheet.UsedRange.Columns.AutoFit()

# 11. マクロを実行する
$target = "Module1.sortASC"
$excel.Run($target) 

# ブックを保存し、エクセルを閉じる
$workbook.Save()
$workbook.Close()
$excel.Quit
```

## VBA
```vb
Sub sortASC()
  ' 12. No.の列を基準に昇順ソートする
  Range("A:L").Sort Key1:=Range("A2"), Order1:=xlAscending, Header:=xlYes
  Range("I:I").NumberFormatLocal = "@" 'I列セルの表示形式を文字列に設定する
End Sub
```

こちらは必須ではないですが、見た目を整えるためにソートなどをVBAマクロで実行しました。

powershellとマクロを連携する方法については、[こちら](https://qiita.com/k12da/items/7a0ed02caa77b4c329e3)もご参考ください。

## 補足
### ノードとは
XMLにおけるノードとは、XML文書の構造を定義する**タグ**（要素）を表しています。

#### ノードのツリー構造
古めの記事から引用しましたが、ノードのツリー構造は以下のようなイメージです。
![fig01.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/677000/0ea63226-9c89-6b62-db7d-31ce9357dbae.gif)
> 引用：[XPathによるノードの指定法を理解する](https://atmarkit.itmedia.co.jp/fxml/rensai2/xmlmaster12/master12.html)


### ノードの種類と対応
|ノードの種類	|XMLドキュメント内の対応|
|---|---|
|ルートノード	|XMLドキュメントで一番トップレベルのノード（ルート要素ではない）
|要素ノード	|要素
|属性ノード	|属性
|テキストノード	|要素の値
|名前空間ノード	|名前空間
|処理命令ノード	|処理命令
|コメントノード	|コメント

今回のコードに置き換えて考えると、要素ノードの中の**テキストノードの値**を取得しています。

### ハッシュテーブルとは
ハッシュテーブルとは、キーと値のペアを効率的に格納し、検索するためのデータ構造です。

#### ハッシュテーブルの仕組み
- **ハッシュ関数** : キーを数値に変換する関数です。この数値をハッシュ値と言います。ハッシュ値は、通常、配列のインデックスとして使用されます
- **配列**: ハッシュ値に対応するインデックスに、キーと値のペアを格納します

![powershell-hashtable.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/677000/e9ef0462-abbc-2b54-dddc-e5fe3a3699f6.png)
（言葉では難しいと思うので、図で表しますね）

> 引用: [【PowerShell】ハッシュテーブルにデータを追加・参照方法](https://buralog.jp/powershell-how-to-use-hashtable-add-data-ref/)

今回の場合は、`"No" = $fileNumber`のようにキー（=No）と値（=$fileNumber）のペアをハッシュテーブルに追加しています。

# 反省点
## ノード取得の仕方
以下のようにノードを取得する変数を分けるとループ処理が増えてしまい、処理時間も増えるなどパフォーマンスが低下する。
```powershell
# 5. ディスクのノードを取得する
  $DiskNodeEntries = $xml.SelectNodes("/root/report/abc/abc_report/entries[@name='erasures']/entries[@name='target']")

  $otherEntries = $eml.SelectNodes("/root/report/abc/abc_report/entries[@name='erasures']")
```
これ、一つにまとめられる。
`$otherHashTable = @()`の初期化するコードを`Get-ChildItem -Path $selectedPath -Filter *.xml | ForEachObject {...`の外に書けばよかった。
↓↓↓
```powershell
$DiskNodeEntries = $xml.SelectNodes("/root/report/abc/abc_report/entries[@name='erasures']")
```

# おわりに
今回は自分が作りたいものというよりは、人からの要望ありきで作成したので感謝された時はとてもやりがいを感じました。
所々端折るところもありましたが、最後まで読んで頂きありがとうございました。

# 参考記事
- [ハッシュテーブル(Hash Table)を簡単に理解しよう](https://qiita.com/tenten1010/items/da4084f937ad07e70164)
- [XPathによるノードの指定法を理解する](https://atmarkit.itmedia.co.jp/fxml/rensai2/xmlmaster12/master12.html)
- [【PowerShell】ハッシュテーブルにデータを追加・参照方法](https://buralog.jp/powershell-how-to-use-hashtable-add-data-ref/)