PowerShellで複数のExcelファイルから複数のキーワードを検索する

## 背景

検索ワード（「案件名A」「パラメータX」など）が書かれたExcelドキュメントを指定フォルダ配下から手早く特定したい事情がありました。
先人が既に投稿（[PowerShellで複数のExcelファイルを一括検索する](https://qiita.com/nejiko96/items/b423e2dda90181ef524e)）されてましたが少しコードを改修したので投稿させていただきます。

　※**改修ポイント（できるようになったこと）**
　　・**複数の検索ワード**を指定
　　・**ショートカットから起動**
　　・**検索結果一覧**を別のExcelに出力

## PowerShellで実装したこと

コマンドプロンプトでいうtree的なコマンドからファイル一覧を取得します。（拡張子からExcelを特定）
Excelを1ファイルずつ読取専用モードで開き、Excelメソッド（[Find](https://docs.microsoft.com/ja-jp/office/vba/api/excel.range.find)、[FindNext](https://docs.microsoft.com/ja-jp/office/vba/api/excel.range.findnext)とか）を利用して検索ワードを探します。
検索ワードがみつかれば、対象Excelファイルの情報を一覧に出力します。

　　※前提1：Windowsのファイルシステムでみえるディレクトリが対象です。Web（ファイルストレージ等）のディレクトリには非対応です(-_-;)
　　※前提2：バージョンはPowerShell 5.1
　　※前提3：Excel内で<font color='blue'>検索しているのは</font><Strong>「セルのみ」</Strong>です。<Strong>★シート名、画像、吹き出し、テキストボックス</Strong>を<font color='red'>検索しません</font>。

## 使い方

　1.コードをスクリプトファイル（.ps1）として保存する
　2.ショートカットをつくってプロパティのリンク先を編集する
　　※リンク先のパスを消さず、左端に「powershell -NoExit -ExecutionPolicy Bypass 」を追記する（-NoExit：処理終了後のコンソールを閉じない。）
　3.ショートカット経由でスクリプトを起動する
　4.メモ帳が起動するので<strong>"検索対象ディレクトリ"</strong>と<strong>"検索ワード"</strong>を入力し、保存して閉じる（Ctrl＋S → Ctrl＋W が早いです）
　5.あとはコンソールのメッセージどおりに入力していけば、ファイル一覧が生成されます

## コード

・メモ帳からのパラメータ入力について、個人用途ですしメモ帳経由にしない（スクリプトにべた書きした）方が使いやすいかもしれません...(－ω－；)
・ファイル一覧をExcelで作る（MakeFileListToExcel関数）について、過去投稿（[PowerShellでExcelにファイル一覧を出力させる](https://qiita.com/miya_hira8/items/96e35dc3ee2a0ebf5ff7)）の丸コピです。

```複数のExcelファイルから複数のキーワードを検索する.ps1
# Excelから検索ワードを探す
Class SearchInExcel{
    hidden [object]$xl

    SearchInExcel()
    {
        $this.xl = New-Object -ComObject Excel.application
    }

    [object]FindWordsFromExcel($search_words,$fp_target_excel)
    {
        # Excelパスワードについて、未設定なら任意の文字列で開ける(引数をみてない？)
        #   Openメソッドは以下参照
        #   https://docs.microsoft.com/ja-jp/office/vba/api/excel.workbooks.open 
        $password = "" 
        $readonly = $true
        $wb = $this.xl.Workbooks.open($fp_target_excel,[Type]::Missing,$readonly,[Type]::Missing,$password)

        $search_word_location = @()

        foreach($ws in $wb.Worksheets)
        {
            foreach($search_word in $search_words)
            {
                # Range.Find メソッド (Excel)は以下参照
                #   https://docs.microsoft.com/ja-jp/office/vba/api/excel.range.find
                $found_cell_1st = $found_cell = $ws.Cells.Find($search_word)

                # 検索結果が1件以上(not null)ならば whileループ突入。
                #   1件目に検索ヒットしたセルと同じセルを検索したらループ抜ける。
                While($found_cell -ne $null) 
                {
                    $search_word_location += [ordered]@{
                        FullName_lnk = $wb.fullname
                        SearchWord   = $search_word
                        BookName     = $wb.Name
                        SheetName    = $ws.Name
                        CellAddress  = $found_cell.Address()
                        CellText     = $found_cell.Text
                    }

                    $found_cell = $ws.Cells.FindNext($found_cell)

                    if ($found_cell.Address() -eq $found_cell_1st.Address()) 
                    {
                        break
                    }
                }
            }
        }

        $wb.Close(0)
        $wb = $null

        Return $search_word_location
    }

    [void]Quit()
    {
        $this.xl.Quit()
        $this.xl = $null
        [GC]::Collect()
    }
}


# ファイル一覧をExcelで作る
function MakeFileListToExcel
{
    param(
        $entrys
       ,$dn_output = $PSScriptRoot
    )
    
    $fp_output = Join-Path $dn_output ("filelist_{0}.xlsx" -f (Get-Date).ToString("yyyyMMddhhmmss"))

    $xl = New-Object -ComObject Excel.application
    $wb = $xl.Workbooks.add()
    $ws = $wb.WorkSheets.item(1)
    $ktn = $ws.cells(1,1)


    # 連想配列の項目名を書き込む。ヘッダ行として
    $row_offset = $col_offset = 0
    [Array]$header_row = $entrys[0].keys

    $header_row |
    %{
        $ktn.offset($row_offset, $col_offset) = $_
        $col_offset += 1
    }

    $row_offset = 1


    # エントリを書き込む。項目名の末尾が"_lnk" かつ 実在パスの場合はハイパーリンク化
    $entrys | 
    %{
        $entry = $_
        $col_offset = 0

        foreach($col_name in $header_row) 
        {
            $ktn.offset($row_offset,$col_offset) = $entry.$col_name 

            if(($col_name -match ".*_lnk$") -and ($entry.$col_name -notin @("",$null)) -and (Test-Path($entry.$col_name)))
            {
                $ktn.offset($row_offset,$col_offset) = '=Hyperlink("{0}")' -f $entry.$col_name
            } 

            $col_offset += 1
        }

        $row_offset += 1
    }

    [void]$ktn.AutoFilter()

    $wb.SaveAs($fp_output)
    $xl.Quit()
    $ws = $wb = $xl = $null
    [GC]::Collect()

    Write-Output $fp_output
    Return
}


# パラメータをユーザにメモ帳で入力させる
function GetUserInputFromNotepad{
    param(
        $entrys
       ,$dn_output = $PSScriptRoot
    )

    $fp_userinput = Join-Path $dn_output "ファイルを閉じたら処理がすすみます.txt"
    $operator = "="

    $default_output = @()

    foreach($entry_col in $entrys.keys) 
    {
        $default_output += $entry_col + $operator + $entrys.$entry_col + "`n"
    }

    $default_output | Out-File -Filepath $fp_userinput -Encoding default
    Start-Process -Wait notepad $fp_userinput 

    $body_userinput = Get-Content $fp_userinput
    Remove-Item $fp_userinput

    [array]$entry_cols = $entrys.keys

    foreach($entry_col in $entry_cols)
    {
        $entrys.$entry_col = $body_userinput | ?{$_ -match "^$entry_col"} | 
        %{
            ($_ -split $operator,2)[1]    
        }
    }

    Write-Output $entrys
    Return
}


# メイン処理はココから
Write-Output "メモ帳に検索対象ディレクトリと検索ワードを入力してください`n　※入力情報を`"`"や`'`'で囲わないでください`n" 
Write-Output "メモ帳を保存して閉じたら処理がすすみます`n" 
$input_params = @{
    root_directory = "C:\Users\hoge\Desktop\"
    search_words   = "default string1,default string2"
}

#$input_params = GetUserInputFromNotepad -entrys $input_params -dn_output ("C:\Users\hoge\Desktop")
$input_params = GetUserInputFromNotepad -entrys $input_params

if($input_params.root_directory.trim() -in @("",$null)){
    Write-Output "ディレクトリが未入力です（>_<）"
    Exit
}

if(-not ((Test-Path $input_params.root_directory) -and ((Get-Item $input_params.root_directory) -is [System.IO.DirectoryInfo])))
{
    Write-Output "実在するディレクトリを入力してください（>_<）"
    Exit
}

$root_directory = $input_params.root_directory
$search_words   = $input_params.search_words


# 検索対象ファイルパスをセット
$file_kind = "*.xlsx","*.xls"
Write-Output ("ディレクトリから対象ファイル({0})を検索中..." -f ($file_kind -join " "))

[array]$fp_list_target = Get-ChildItem $root_directory -Include $file_kind -Recurse

# 検索ワードをセット。検索おわらなくなるので空白を除外しておく。
$separater = ","    #カンマ(",")を含む単語を検索したいときはココをいじる
$entered_words = $search_words -split $separater | ?{$_.trim() -notmatch "^$"}


Write-Output ("　検索対象ファイル数：{0}件" -f $fp_list_target.count) 
Write-Output ("　検索ワード　　　　：{0}" -f ($entered_words -join ",")) 
Read-Host ("検索を始めますか？[y/n]") | 
%{
    if($_ -ne "y"){Exit}
}

# エラー制御値をセット
$originalErrorActionPreference = $ErrorActionPreference
$ErrorActionPreference = "Continue"


Write-Output ("{0} 対象ファイルからワード検索中...`n" -f (Get-Date).ToString("HH:mm:ss"))
$search_result = @()
$obj_search = [SearchInExcel]::new()

foreach($fp_target in $fp_list_target) 
{  
    try {
        $search_result += $obj_search.FindWordsFromExcel($entered_words,$fp_target)
    } catch [Exception] {
        Write-Output ("対象ファイル:{0}`nエラー内容:{1}`n" -f $fp_target,$Error[0])
    }
}

$obj_search.Quit()

# エラー制御値をセット（戻し）
$ErrorActionPreference = $originalErrorActionPreference

if($search_result.count -eq 0)
{
    Write-Output "検索結果は0件でした...┐(-ω-;)┌"
    Exit
}


Write-Output ("==検索ワードがみつかったExcelファイルは以下の通り==")
Write-Output ($search_result.BookName | Sort-Object -Unique)
Write-Output ("=============================================`n")


Write-Output ("{0} {1}件の検索結果をExcelに書き込み中..." -f (Get-Date).ToString("HH:mm:ss"),$search_result.count)
#$fp_filelist = MakeFileListToExcel -entrys $search_result -dn_output ("C:\Users\hoge\Desktop")
$fp_filelist = MakeFileListToExcel -entrys $search_result


Write-Output ("{0} 以下にファイル出力済み！`n{1}`n" -f (Get-Date).ToString("HH:mm:ss"),$fp_filelist)
Read-Host "出力ファイルを開きますか？[y/n]" | 
%{
    if($_ -eq "y"){Start-Process $fp_filelist}
}


Write-Output "オシマイ!! (/・ω・)/ =3"
```

## コード作成時によく使ったコマンド（Excelオブジェクトのメソッド&プロパティ確認用）

コンソールからのベタ張り用。Excelオブジェクトの生成。
あとはGet-MemberとかGetTypeとかで調べましょう...(=_= ;)

```Powershell
$xl = New-Object -ComObject Excel.application
$xl.Visible = $true

$wb = $xl.Workbooks.add()            #新規ファイルの場合
$wb = $xl.Workbooks.open("既存ファイルのパス")  #既存ファイルの場合

$ws = $wb.WorkSheets.item(1)         #1シート目を指定する場合
$ws = $wb.WorkSheets("対象シート名")    #シート名で指定する場合
```

コンソールからのベタ張り用。Excelオブジェクトの破棄。
忘れると次回のPC起動と同時にExcelが動いたりします...(ﾟεﾟ;)

```Powershell
$wb.saveas("ファイルの保存先パス") #ファイル保存したい場合
$xl.quit()
$ws = $wb = $xl = $null
[GC]::Collect()
```

## 参考にしたWebサイト

[PowerShellで複数のExcelファイルを一括検索する](https://qiita.com/nejiko96/items/b423e2dda90181ef524e)
[超簡単な PowerShell Class の使い方](http://www.vwnet.jp/Windows/PowerShell/2017082001/PSv5Class01.htm)
[Workbooks.Open メソッド (Excel)](https://docs.microsoft.com/ja-jp/office/vba/api/excel.workbooks.open)
[powershellでパスワード付きのエクセルのパスワードチェックを行う](https://qiita.com/areanoblog/items/96604cc7738456e3e58f)
[Type.Missing フィールド](https://docs.microsoft.com/ja-jp/dotnet/api/system.type.missing?view=net-6.0)
[about_Hash_Tables](https://docs.microsoft.com/ja-jp/powershell/module/microsoft.powershell.core/about/about_hash_tables?view=powershell-7.2)
[about_Split](https://docs.microsoft.com/ja-jp/powershell/module/microsoft.powershell.core/about/about_split?view=powershell-7.2)

## 参考図書

・PowerShell 5.1のお勉強にオススメです
[PowerShell実践ガイドブック クロスプラットフォーム対応の次世代シェルを徹底解説](https://book.mynavi.jp/ec/products/detail/id=90597)

以上