Windows PowerShellでフォルダ内の重複するファイルを調べる

前の記事では(私の想像で) 人間が手作業している日常業務を自動化する例としてWindows PowerShellでExcelファイルを編集すると言う内容を書きましたが、余りPowerShellっぽさがないスクリプトになってしまったので、今度はPowerShellっぽいスクリプトになるようにフォルダ内の重複するファイルを調べてみます


とりあえず今回のサンプルコードとしてはピクチャフォルダ配下のファイルを調べることにします

まずはファイルの一覧を取ってみましょう

Get-ChildItem -Path ([Environment]::GetFolderPath('MyPictures')) -File -Recurse -Force

はい、取れました

ピクチャフォルダを[Environment]::GetFolderPath('MyPictures')で取得してGet-ChildItem-Pathに設定し、ファイルだけ欲しいので-File付けて、子アイテムがフォルダの場合その中身も再帰的に取得するよう-Recurse付けて、ついでに隠しファイルも取れるよう-Forceも付けました

いままでDOSでバッチを書いていた身からすると、PowerShellは本当に強力です

次に同一ファイルを特定する情報の取得ですが、今回はHashを使う事にします

Hashが何かについてはこの記事では説明しないので、各自ハッシュ関数で検索して下さい

Windows PowerShellではversion 4から標準コマンドレットにGet-FileHashと言う、ファイルハッシュを取得するその名の通りのコマンドレットが追加されていますので、先ほど取得したファイルリストをパイプ (|) を使ってGet-FileHashに流し込んでみます

Get-ChildItem -Path ([Environment]::GetFolderPath('MyPictures')) -File -Recurse -Force |
    Get-FileHash -Algorithm SHA256;

はい、できました

Get-FileHashはパイプラインから流れてくるSystem.IO.FileSystemInfoを直接受け取れるので上記のように書けます

ちなみに、-AlgorithmパラメータにSHA256を与えていますがここはパフォーマンスと相談して好きなアルゴリズムを採用してください (今回のサンプルコードでは悪意のある第三者が改竄したファイルでhashを衝突させてくる心配はほぼないですし)

そして、ユニークではないhashを持つObjectだけ出力したいので、とりあえず今回はSort-Object -Property HashでHashでソートした上で直前のObjectのhashと同じかどうかを判定することにします

[String] $prevHash = '';
[PSCustomObject] $prevObject = $null;
Get-ChildItem -Path ([Environment]::GetFolderPath('MyPictures')) -File -Recurse -Force |
    Get-FileHash -Algorithm SHA256 |
    Sort-Object -Property Hash |
    Foreach-Object {If($_.Hash -eq $prevHash){$prevObject; $_;} $PrevObject=$_;$PrevHash=$_.Hash;}

しかし、これだと一致するファイル A1,A2,A3 があった場合、A1,A2,A2,A3 と、A2が2回出力されてしまいます

そこで、その後Select-Object -Property * -Uniqueとして重複したObjectを取り除くことにします

[String] $prevHash = '';
[PSCustomObject] $prevObject = $null;
Get-ChildItem -Path ([Environment]::GetFolderPath('MyPictures')) -File -Recurse -Force |
    Get-FileHash -Algorithm SHA256 |
    Sort-Object -Property Hash |
    Foreach-Object {If($_.Hash -eq $prevHash){$prevObject; $_;} $PrevObject=$_;$PrevHash=$_.Hash;} |
    Select-Object -Property * -Unique;

以上でピクチャフォルダ配下の重複したファイルが分かるようになりましたが、重複はしているがそれぞれ必要なファイルの場合もありますので、削除や移動はせず、CSVファイルに出力することにします

CSVに出力する場合、Export-CSVと言うそのままの名前のコマンドレットを使います

[String] $prevHash = '';
[PSCustomObject] $prevObject = $null;
[String] $CsvFullName = Join-Path -Path ([Environment]::GetFolderPath('Desktop')) -CHildPath '重複ファイルリスト.csv'
Get-ChildItem -Path ([Environment]::GetFolderPath('MyPictures')) -File -Recurse -Force |
    Get-FileHash -Algorithm SHA256 |
    Sort-Object -Property Hash |
    Foreach-Object {If($_.Hash -eq $prevHash){$prevObject; $_;} $PrevObject=$_;$PrevHash=$_.Hash;} |
    Select-Object -Property * -Unique |
    Export-CSV -Path $CsvFullName -Encoding Default -Force -NoTypeInformation;

ほぼ完成です

ですが、出力されたCSVを開いてみると先頭の列はAlgorithmという、今回は全行同じ値が必ず入っているので要らない情報が有ったり、逆に (同じファイルとはいえ) 最終更新日等があった方がどのファイルを消すかの参考情報になって便利などの微妙に使い勝手が悪いことが気になります

そこで、Get-FileHashの行を書き換え、素のGet-FileHashの出力結果ではなく、Select-Objectを使って必要な情報を持ったPSCustomObjectを次のパイプに渡すように変更してみます

[String] $prevHash = '';
[PSCustomObject] $prevObject = $null;
[String] $CsvFullName = Join-Path -Path ([Environment]::GetFolderPath('Desktop')) -CHildPath '重複ファイルリスト.csv'
Get-ChildItem -Path ([Environment]::GetFolderPath('MyPictures')) -File -Recurse -Force |
    Select-Object -Property Name,Directory,FullName,Length,Extension,LastWriteTime,CreationTime,@{Name='Hash';Expression={(Get-FileHash -Path $_.FullName -Algorithm SHA256).Hash}} |
    Sort-Object -Property Hash |
    Foreach-Object {If($_.Hash -eq $prevHash){$prevObject; $_;} $PrevObject=$_;$PrevHash=$_.Hash;} |
    Select-Object -Property * -Unique |
    Export-CSV -Path $CsvFullName -Encoding Default -Force -NoTypeInformation;

最後に、このコードをps1ファイルに保存するときにはファイルの先頭に#Requires -Version 4.0と実行時の最低バージョンの指定を書いておくと良いでしょう

今回のスクリプトではversion 4.0から追加されたGet-FileHashを使っていますが、2017年12月現在、Version 4.0が入っていない環境は一応残っています (参考: Windows PowerShell のシステム要件 | Microsoft Docs)

そんな訳で最終的にはこんなバッチファイルになりました

#Requires -Version 4.0

[String] $prevHash = '';
[PSCustomObject] $prevObject = $null;
[String] $CsvFullName = Join-Path -Path ([Environment]::GetFolderPath('Desktop')) -CHildPath '重複ファイルリスト.csv'
Get-ChildItem -Path ([Environment]::GetFolderPath('MyPictures')) -File -Recurse -Force |
    Select-Object -Property Name,Directory,FullName,Length,Extension,LastWriteTime,CreationTime,@{Name='Hash';Expression={(Get-FileHash -Path $_.FullName -Algorithm SHA256).Hash}} |
    Sort-Object -Property Hash |
    Foreach-Object {If($_.Hash -eq $prevHash){$prevObject; $_;} $PrevObject=$_;$PrevHash=$_.Hash;} |
    Select-Object -Property * -Unique |
    Export-CSV -Path $CsvFullName -Encoding Default -Force -NoTypeInformation;

パイプをどんどん繋げてObjectを流していると、俺、シェル書いてるって気分になって楽しいです