【ファイルサーバー】ゴミ候補を一覧出力するプログラム【容量圧縮・容量削減】

# tl;dr
- ファイルサーバー上のファイルを「ゴミっぽい」かどうか判定するプログラム(powershell&pythonの組み合わせ)を作ったよ
- プログラムは [これ](#プログラム) だよ

# やりたいこと

- ファイルサーバー上のファイルが「ゴミ」かどうか判定したい（判定基準は以下）
    - ファイル名が似ている（ゴミかもしれない：「問い合わせ.xls」「問い合わせ_.xls」とか明らかに一時的に作りましたよね感。）
        - ファイル名・パスに「old」「tmp」などが含まれる（ほぼ確実にゴミ）
        - ファイル名・パスに「yyyymmddっぽい8桁の数字」が入るモノと入らないものがある
        - などなど
    - アクセス日時が「◯年以上前」（5年前とかならカビてるので、どっかにアーカイブしておきたい）
    - ファイルサイズが大きいもの「500MB以上」とか

感覚値として、「ファイル名が似ている」ってのが一番数を占めている気がするので、それを見える化したい。
けど、その「ファイル名が似ている」をどう判定するのかが悩ましい。

# 実現してみた
## 実現した大まかなロジック
以下のロジックをある程度の単位で区切ったディレクトリパス毎に実行します。（targetList.txtに定義する）

1. 対象フォルダのファイルの一覧取得
2. ループ処理（ファイルサイズ1MB以上を対象にする）
    1. 最終アクセス日時が1年以上前の対象を「output_oldfiles.txt」として出力
    2. ファイルサイズが500MB以上の対象を「output_bigfiles.txt」として出力
    3. 後続のファイル名類似判定処理に引き渡すためのファイル名一覧「filelist.txt」を出力
3. 「ファイル名が似ている」の判定（ファイルサイズ1MB以上を対象にする）
    1. ファイルの数を5000件ずつに分割（処理時間短縮の工夫）
    2. 全ファイルを相互に突き合わせて、文字列の一致度が一定以上の対象を「似ているファイル」とする
    3. 似ているファイルで出力すると数が多すぎるので、ディレクトリ単位でファイル数をカウント
    4. 似ているファイルが多い順でディレクトリをソートして、ファイル数と共に「output_similarfiles.txt」として出力する。

基本はpowershellが使いやすそうだけど、「ファイル名が似ている」判定はpythonのライブラリ使った感じ。

## プログラム
以下 powershell & python & インプットファイルの3つ。
（もっと良い書き方あれば知りたい）

- fileseverGC.ps1 ： 主処理。ファイルの一覧を取得したりする。
- similarNameCheck.py ： パス名が類似している対象を出力するプログラム。主処理から呼ばれる。
- targetList.txt ： 対象とするパスのリストを記載する。（「PJフォルダ」ぐらいの感じ）

```powershell:fileseverGC.ps1
## ----------------------------------------------------------------------------------
## 初期宣言
## ----------------------------------------------------------------------------------
Param($Arg1,$Arg2)
echo "◆「削除対象検索」を開始します◆"
$separator = "-----------------------------------"
$watch = New-Object System.Diagnostics.StopWatch
$watch.Start()

# ファイルサーバアクセス時の認証情報
$user = $Arg1
$spass = ConvertTo-SecureString $Arg2 -AsPlainText -Force

## ----------------------------------------------------------------------------------
## 対象ディレクトリ取得＆出力ファイル名定義 → ループ処理へ
## ----------------------------------------------------------------------------------
$currentPath = Split-Path -Parent $MyInvocation.MyCommand.Path
$targetList_Path = $currentPath + "\targetList.txt"
$f = (Get-Content $targetList_Path) -as [string[]]
$cnt = 1
foreach ($line in $f) 
{
    ## ----------------------------------------------------------------------------------
    ## 変数宣言
    ## ----------------------------------------------------------------------------------
    $separator
    $separator
    $separator
    echo "◆ループ処理開始 $cnt 回目 ◆"
    $line
    
    $source = $line
    $cntCheckTarget = 0
    $i = 0
    $outFile_OldFiles = $currentPath + "\output_oldfiles_$cnt.txt"
    $outFile_BigFiles = $currentPath + "\output_bigfiles_$cnt.txt"
    $filelist_path    = $currentPath + "\filelist_$cnt.txt"
    Remove-Item $outFile_OldFiles
    Remove-Item $outFile_BigFiles
    
    $t = $watch.Elapsed.TotalMinutes.ToString("0.000")
    echo "現時点のelapsedTimeは $t 分です"

    
    # 文字列が存在するときだけ処理する
    if (-Not([String]::IsNullOrEmpty($source)))
    {
        ## ----------------------------------------------------------------------------------
        ## 直接ファイルサーバーにはアクセスできないため、PSdriveというアクセス方法に変換する
        ## ----------------------------------------------------------------------------------
        $separator
        $separator
        $separator
        echo "◆PSdriveを作成します◆"
        
        $drivepath = $source
        $drivepath_psdrive = "Fsv:"
        $source_path = "Fsv:\"
        $cred = New-Object System.Management.Automation.PSCredential($user, $spass)          # 資格情報生成
        Remove-PSDrive -Name Fsv                                                             # 一度消す
        New-PSDrive -Name Fsv -PSProvider FileSystem -Root $drivepath -Credential $cred      # アクセス可能なフォーマットにする
        
        $t = $watch.Elapsed.TotalMinutes.ToString("0.000")
        echo "現時点のelapsedTimeは $t 分です"

        ## ----------------------------------------------------------------------------------
        ## ファイルの一覧を取得
        ## ----------------------------------------------------------------------------------
        $separator
        $separator
        $separator
        echo "◆対象ファイルを取得します◆"
        $list = Get-ChildItem -Recurse -Name -File -Path $source_path
        $size_all = $list.Length
        echo "総ファイル件数は $size_all 件です"
        $t = $watch.Elapsed.TotalMinutes.ToString("0.000")
        echo "現時点のelapsedTimeは $t 分です"

        ## ----------------------------------------------------------------------------------
        ## python連携データ作成＆アクセス日時判定＆ファイルサイズ判定
        ## ----------------------------------------------------------------------------------
        $separator
        $separator
        $separator
        echo "◆python連携データ作成＆アクセス日時判定＆ファイルサイズ判定◆"
        $listStr = ""
        foreach($item in $list)
        {
            $fullpath = $source_path + "\" + $item                     # フルパス
            $outputpath = $drivepath_other + ($fullpath -replace [regex]::Escape($drivepath_psdrive), "") # 出力用パス
            
            if ($(Get-ItemProperty $fullpath).Length -ge 1MB )         # 1MB以下は無視
            {
                ## ----------------------------------------------------------------------------------
                ## pythonに引き渡すデータ作成
                ## ----------------------------------------------------------------------------------
                $listStr = $outputpath + "/" + $listStr
                $cntCheckTarget++
                
                ## 総パス数が255文字以上の対象はファイルにアクセスできない（windows制約）
                if ($fullpath.Length -le 255)
                {
                    ## ----------------------------------------------------------------------------------
                    ## アクセス日時判定
                    ## ----------------------------------------------------------------------------------
                    $timestamp=$(Get-ItemProperty $fullpath).LastAccessTime    # アクセス日時
                    $estimatedDays = ((Get-Date) - $timestamp).Days            # 経過日数
                    if (!([String]::IsNullOrEmpty($estimatedDays)) -And ($estimatedDays -gt 365)) # 365日≒1年
                    {
                        $outputpath | Out-File $outFile_OldFiles -encoding UTF8 -append
                    }
                    
                    
                    ## ----------------------------------------------------------------------------------
                    ## ファイルサイズ判定
                    ## ----------------------------------------------------------------------------------
                    if ($(Get-ItemProperty $fullpath).Length -ge 500MB )         # 500MB以上はヤバイ
                    {
                        $outputpath | Out-File $outFile_BigFiles -encoding UTF8 -append
                    }
                }
            }
            ## ----------------------------------------------------------------------------------
            ## ログ
            ## ----------------------------------------------------------------------------------
            $i++
            if ($i % 300 -eq 0)  # だいたい1分おきくらいに出力される
            {
                $t = $watch.Elapsed.TotalMinutes.ToString("0.000")
                echo "◆処理済み： $i / $size_all 、elapsedTime： $t min 、類似チェック対象： $cntCheckTarget 件◆"
            }
        }

        $t = $watch.Elapsed.TotalMinutes.ToString("0.000")
        echo "◆処理済み： $size_all / $size_all 、elapsedTime： $t min 、類似チェック対象： $cntCheckTarget 件◆"

        ## ----------------------------------------------------------------------------------
        ## pythonにデータを渡すためにファイル出力
        ## ----------------------------------------------------------------------------------
        $separator
        $separator
        $separator
        echo "◆pythonにデータを渡すためにファイル出力◆"

        # ファイル書きこみ
        $filelist_file = New-Object System.IO.StreamWriter($filelist_path, $false, [System.Text.Encoding]::GetEncoding("sjis"))
        $filelist_file.WriteLine($listStr)
        $filelist_file.Close()

        $t = $watch.Elapsed.TotalMinutes.ToString("0.000")
        echo "現時点のelapsedTimeは $t 分です"


        ## ----------------------------------------------------------------------------------
        ## pythonでの類似ファイル判定処理
        ## ----------------------------------------------------------------------------------
        $separator
        $separator
        $separator
        echo "◆類似ファイル名判定◆"
        python similarNameCheck.py $cnt $filelist_path

        $t = $watch.Elapsed.TotalMinutes.ToString("0.000")
        echo "現時点のelapsedTimeは $t 分です"
    }
    
    ## ----------------------------------------------------------------------------------
    ## ループクロージング
    ## ----------------------------------------------------------------------------------
    $separator
    $separator
    $separator
    echo "◆ループ処理終了 $cnt 回目 ◆"
    $t = $watch.Elapsed.TotalMinutes.ToString("0.000")
    echo "現時点のelapsedTimeは $t 分です"
    
    $cnt++

}


## ----------------------------------------------------------------------------------
## クロージング
## ----------------------------------------------------------------------------------
$targetList_File.Close()
$separator
$separator
$separator
echo "◆処理終了◆"
$watch.Stop()
$t = $watch.Elapsed.TotalMinutes.ToString("0.000")
echo "execution time: $t min"
```

```python:similarNameCheck.py
# -*- coding: utf-8 -*-
import difflib
from pathlib import Path
import math
import sys
import os
import time
import copy
start = time.time()
argvs = sys.argv  # コマンドライン引数を格納したリストの取得

cnt = argvs[1]
filename = argvs[2]

# ファイルをオープンする
textfile = open(filename, "r")

# すべて読み込む
text = textfile.read()
list = text.split("/")
length = len(list)
separator = "-----------------------------------"

separatorNum_longest = 0
checkedPathList = []
isBreak = False

similarCheckTargetListList1 = [[]]
similarCheckTargetListList2 = [[]]

beforeCheckpath = "<:>"
i = 0
total_length=0

if length > 5000:
    print(u"◆listサイズが大きいため分割します  総件数： " + str(length) + u" 件◆")
    for path in list:
        dirname = os.path.dirname(path)
        if not (dirname in checkedPathList):
            dirElmList = path.split("\\")
            checkpath = ""
            for dirElm in dirElmList:
                if checkpath == ""  and  dirElm == "":
                    checkpath = checkpath + chr(165)
                elif dirElm == "p2fsvt02":
                    checkpath = checkpath + dirElm
                else:
                    checkpath = checkpath + chr(165) + dirElm
                checkpath = checkpath.replace(chr(165), "\\")
                
                if not (beforeCheckpath in checkpath):
                    
                    l = len([s for s in checkedPathList if s in repr(checkpath)])
                    
                    if l == 0:
                        list_in = [s for s in list if checkpath in s]
                        l = len(list_in)
                        
                        if l <= 5000 and l > 1:
                            checkedPathList.append(checkpath)
                            beforeCheckpath = checkpath
                            similarCheckTargetListList1.append(list_in)
                            copiedList = copy.deepcopy(list_in)
                            similarCheckTargetListList2.append(copiedList)
                            break
                        
                
        #ログ
        i += 1
        
        #1000件ごとに出力
        if((i % 1000) == 0):
            elapsed_time = time.time() - start
            sec = int(elapsed_time % 60)
            min = int(elapsed_time / 60)
            print(u"◆処理状況 " + str(i) + u" / " + str(length) + u" 、◆python処理経過時間 " + str(min) + u" min " + str(sec) + u" sec")
        
    print(u"◆処理状況 " + str(i) + u" / " + str(length) + u" 、◆python処理経過時間 " + str(min) + u" min " + str(sec) + u" sec")
else:
    similarCheckTargetListList1.append(list)
    copiedList = copy.deepcopy(list)
    similarCheckTargetListList2.append(copiedList)

listMax = len(similarCheckTargetListList1)
listCnt = 0
dirname = os.path.dirname(filename)
outputFileName = dirname + "\output_similarfiles_" + cnt + ".txt"
similarFilesList = []
len_similarFilesList = 0
similarFilesDict = {}
toalFileNum = 0

for list1, list2 in zip(similarCheckTargetListList1, similarCheckTargetListList2):
    listCnt += 1
    
    checkedFilesList = []
    i = 0
    
    l = len(list1)
    toalFileNum = toalFileNum + l

    print(u"◆pythonでの類似ファイル名判定を開始します  対象リスト： " + str(listCnt) + u" / " + str(listMax) + u"  対象件数： " + str(l) + u" 件◆")
    for path1 in list1:
        if not (path1 in checkedFilesList): #チェック済みならチェックしない
            #突き合わせの準備
            similarFilesList_tmp = []
            basename1 = os.path.basename(path1)
            dirname1 = os.path.dirname(path1)
            
            path1_len = len(path1)
            min = path1_len * 0.85
            max = path1_len / 0.85
            
            for path2 in list2:
                path2_len = len(path2)
                
                #文字列の長さが違う場合は判定しない
                if path2_len >= min and path2_len <= max:
                   
                    #突き合わせの準備
                    basename2 = os.path.basename(path2)
                    dirname2 = os.path.dirname(path2)
                    
                    #ファイル名とパス名を分けて、それぞれ一致率を判定
                    #（フルパスだけで判定すると、同じディレクトリのファイルが結構ヒットする）
                    file_ratio = difflib.SequenceMatcher(None, basename1, basename2).ratio()
                    dir_ratio = difflib.SequenceMatcher(None, dirname1, dirname2).ratio()
                    
                    #両方の一致率が高い状態が、最終的に「似ているファイル」と判断する
                    ratio = file_ratio*dir_ratio*100
                    
                    #一致率85以上の対象は「似ている」と判断
                    if ratio >= 85:
                        similarFilesList_tmp.append(path2)
                        checkedFilesList.append(path2)
                
            #自分以外の対象を含んでいたら、出力するために、出力リストに加える
            if (len(similarFilesList_tmp) >= 2):
                similarFilesList.append(similarFilesList_tmp)
                len_similarFilesList = len_similarFilesList + len(similarFilesList_tmp)

                for item_path in similarFilesList_tmp:
                    dirname = os.path.dirname(item_path)
                    if (dirname in similarFilesDict):
                        similarFilesDict[dirname] += 1
                    else:
                        similarFilesDict[dirname] = 1
        
        #チェック済みの対象を除外して、二重チェックされないようにする
        list2.remove(path1)
        
        #ログ
        i += 1
        
        #50件ごとに出力
        if((i % 50) == 0):
            elapsed_time = time.time() - start
            sec = int(elapsed_time % 60)
            min = int(elapsed_time / 60)
            print(u"◆処理状況 " + str(i) + u" / " + str(l) + u" 、◆python処理経過時間 " + str(min) + u" min " + str(sec) + u" sec")
        

    elapsed_time = time.time() - start
    sec = int(elapsed_time % 60)
    min = int(elapsed_time / 60)
    print(u"◆処理状況 " + str(i) + u" / " + str(l) + u" 、◆python処理経過時間 " + str(min) + u" min " + str(sec) + u" sec")

print(u"◆類似ファイル件数 " + str(len_similarFilesList) + u" / " + str(toalFileNum) + u" ◆")

if len_similarFilesList > 0:
    print(u"◆〇：1件以上のため出力します◆")

    sortedSimilarFilesList = sorted( similarFilesList, key=len )
    sortedSimilarFilesDict = sorted(similarFilesDict.items(), key=lambda x: -x[1])
    print('len(similarFilesDict):' + str(len(similarFilesDict)))
    print('len(sortedSimilarFilesDict):' + str(len(sortedSimilarFilesDict)))

    with open(outputFileName, mode='w') as f:
        # for filesList in sortedSimilarFilesList:
        #     f.write(separator)
        #     f.write('\n')
        #     f.write('\n'.join(filesList))
        for item in sortedSimilarFilesDict:
            f.write(str(item[1]) + ": " + str(item[0]))
            f.write('\n')
        
else:
    print(u"◆×：0件のため出力しません◆")
```

```text:targetList.txt（例）
\\fileserver\部門名\プロジェクト\プロジェクトA
\\fileserver\部門名\プロジェクト\プロジェクトB
\\fileserver\部門名\プロジェクト\プロジェクトC
\\fileserver\部門名\部門運営
```

### 実行方法

**前提**

- 上記3つのファイルが同一ディレクトリに存在する


```powershell:コマンド例
.\fileseverGC.ps1 id password > .\log.txt 2>&1
```

※idとpasswordは、ファイルサーバー認証用のID/Passwordです。


## 補足

- 「targetList.txt」は、各自で対象ディレクトリを指定してください（必須）
- ファイルサーバーへのアクセスはなるべく減らしてますが、ある程度発生してしまうので、ご注意ください。

# 実行結果
全体件数に比べたら類似ファイル数そんなに多くないな。。。（※容量での割合ではなく、ファイル数での割合ですが・・・）
容量で見たほうがいいのかなー？

合計

|単位|targetList.txtに指定したパス数|総ファイル件数|古いファイル|大きいファイル|類似ファイル|処理時間|
|:--|:--|--:|--:|--:|--:|--:|--:|
|数|7プロジェクト|829,215|3311|33|31,379|348.049分|
|％|7プロジェクト|100.00%|0.399%|0.003%|3.784%|5.8時間|

詳細

|targetList.txtに指定したパス|総ファイル|古いファイル|大きいファイル|類似ファイル|処理時間(分)|
|:--|--:|--:|--:|--:|--:|--:|
|プロジェクトA|39|7|0|3|0.004|
|プロジェクトB|10|2|0|0|0.002|
|プロジェクトC|41|0|0|0|0.001|
|プロジェクトD|34|0|0|2|0.003|
|プロジェクトE|1,287|112|1|81|0.575|
|プロジェクトF|265,134|641|13|12,951|168.681|
|プロジェクトG|562,670|2,549|19|18,342|178.793|

# 苦労したトコロ
## 「ファイル名が似ている」への対応方法
「[Python で文字列の類似度を比較する](https://blog.mudatobunka.org/entry/2016/05/08/154934)」を参考にさせていただき、pythonの「difflib」を使いました。

判定処理のイメージ

```python
# -*- coding: utf-8 -*-
import difflib

str1 = u"スパゲッティー"
str2 = u"スパゲティ"

s = difflib.SequenceMatcher(None, str1, str2).ratio()

print str1, "<~>", str2
print "match ratio:", s, "\n"

# >> スパゲッティー <~> スパゲティ
# >> match ratio: 0.833333333333
```

ただし、フルパスで比較すると、「ファイル名はぜんぜん違う」のに「ディレクトリパスは一緒」っていうケースも高い一致率を示してしまう。

```python
# フルパスでの比較
\fileserver\code1\code2\code3\pjname\subdir1\subdir2\subdir3\subdir4\subdir5\テストバリエーション整理.xlsx <~> \fileserver\code1\code2\code3\pjname\subdir1\subdir2\subdir3\subdir4\subdir5\old\どろんこ大賞.xlsx
match ratio: 0.8817204301075269

# ファイル名だけでの比較（一致率低い）
テストバリエーション整理.xlsx <~> どろんこ大賞.xlsx
match ratio: 0.35714285714285715

# ディレクトリパスでの比較
\fileserver\code1\code2\code3\pjname\subdir1\subdir2\subdir3\subdir4\subdir5 <~> \fileserver\code1\code2\code3\pjname\subdir1\subdir2\subdir3\subdir4\subdir5\old
match ratio: 0.9743589743589743
```

パスの一致率が原因で、非常に高くなっているっぽいので、単純に掛け算にしてみると良い感じ。

```python:ファイルの一致率×パスの一致率
match ratio: 0.3479853479853479
```

当てたいケースの場合、0.87くらいなので、0.80とか0.85くらいを閾値にしてみたらちょうど良さそう。

```python
\fileserver\code1\code2\code3\pjname\subdir1\subdir2\subdir3\subdir4\subdir5\テストバリエーション整理.xlsx <~> \fileserver\code1\code2\code3\pjname\subdir1\subdir2\subdir3\subdir4\subdir5\old\テストバリエーション整理_old.xlsx
match ratio: 0.9591836734693877

テストバリエーション整理.xlsx <~> テストバリエーション整理_old.xlsx
match ratio: 0.8947368421052632

\fileserver\code1\code2\code3\pjname\subdir1\subdir2\subdir3\subdir4\subdir5 <~> \fileserver\code1\code2\code3\pjname\subdir1\subdir2\subdir3\subdir4\subdir5\old
match ratio: 0.9743589743589743

ファイルの一致率×パスの一致率
match ratio: 0.8717948717948718
```

「ゴミファイルはディレクトリ上近くに存在して、似たようなファイル名である」という前提になるけど、これがなんとなく良い気がする。

## 大量のファイル

とにかくファイル数が多いので、以下にこだわる必要があった。
最初とか、4,000 件のファイル数に対して、処理時間 10 時間とかかかってました。。。
（今は、800,000 件のファイル数に対して、処理時間 6 時間！）

- ループの中で無駄な処理をしない
    - ループの中でループ回すのは遅い（当たり前）
        - pythonだと、「in」とか「リスト内包表現」とかもループに含まれるので注意やで。
- 複雑な判定する際は、それ以前に計算量の少ない処理で分岐を作る（バランスが大事）
    - 『一致率を算出して、85％以上なら「似ている」と判定する』の前に、『文字の長さの一致率が85%であること』を判定する
- そもそものファイル件数を絞る
    - ファイルの一致率判定のために、どうしても『ループの中でループを回す』必要があったので、そもそものファイル数を制限する必要があった。

# 最後に

こういう身近な問題を、文句を言うのではなく、着々とプログラムで解決していきたい。

**次やりたいこと**

- 類似ファイルは見つかったけど、どうしたら消してくれるだろうか・・・
- チャットへの通知を自動化したいなー
- 古いファイルは、勝手にアーカイブしたいなー
- もう少し処理を早くしたいなー
    - ファイル取得が一番時間がかかっているのだけど何かいいやり方は無いだろうか・・・

# 参考記事

着想を得た記事

- [同一ファイル名を探す Python プログラム](https://qiita.com/ynomura/items/8f2d966035a54b70de0a)

ファイル名の類似度判定に関する記事

- [Python で文字列の類似度を比較する](https://blog.mudatobunka.org/entry/2016/05/08/154934)：difflibという「文字列比較」のライブラリの利用説明（今回使った）
- [類似文字列検索ライブラリResemblaを公開しました](https://engineering.linecorp.com/ja/blog/detail/226)：「文章」を想定したライブラリっぽいので、今回は使ってない。
- [２つの文字列の類似度を数値化　レーベンシュタイン距離とジャロ・ウィンクラー距離の解説](http://nkdkccmbr.hateblo.jp/entry/2016/08/18/102727)：どちらも「文章」の比較に使うっぽい。
- [文字列の類似度をPythonで計算する](https://xaro.hatenablog.jp/entry/2017/02/24/005126)：数学勉強しなきゃ・・・