ん~、前に紹介した、ダウンロードファイルに溜まってるZipファイルをどんどん解凍していくプログラムにrarファイルも処理できるようにしてみました、そんな感じで、本編行ってみましょう。
ダウンロードしたZIPファイルやRARファイル。「すぐに解凍したいけど、ウイルスが入っていないかちょっと不安…」
そう思って、毎回ファイルを右クリックして「Microsoft Defenderでスキャン」を選んでから解凍している方も多いのではないでしょうか。その一手間、面倒ですよね。
この記事では、そのプロセスを完全に自動化するPythonスクリプトを紹介します。
このスクリプトが行うのは、以下の処理です。
- 対象のアーカイブ(ZIPまたはRAR)を Microsoft Defenderでスキャン
- スキャン結果が「脅威なし」だった場合のみ、安全にファイルを展開
- 展開が成功したら、元のアーカイブファイルを削除
これにより、アーカイブファイルの処理が格段に安全かつ効率的になります。
このスクリプトで出来ること
このスクリプトの主な機能は以下の通りです。
- Defenderによる自動スキャン: Windows標準のMicrosoft Defenderのコマンドラインツール(
MpCmdRun.exe)を自動で検索・実行し、ウイルススキャンを行います。 - ZIPとRARの両方に対応:
.zipだけでなく.rar形式のアーカイブファイルも処理できます。 - 安全な展開(Zip Slip対策): アーカイブ内に不正なパス(
../../など)が含まれていても、意図しない場所にファイルが展開される「Zip Slip」脆弱性を防ぎます。 - インテリジェントなフォルダ整理:
MyFile.zipを解凍したらMyFile/MyFile/のように二重にフォルダができてしまった…という経験はありませんか? このスクリプトは、単一のフォルダだけが含まれている場合、自動で階層を一つ浅くします。- 逆に、アーカイブ全体でファイルが1つだけの場合、そのファイルだけを抽出します。
- 自動クリーンアップ: スキャンと展開に成功した場合、元のアーカイブファイル(
.zipや.rar)を自動的に削除し、フォルダを整理します。 - 柔軟な実行対象: 引数なしで実行すれば「ダウンロード」フォルダ内の全アーカイブを、引数で指定すれば特定のファイルやフォルダ内のアーカイブだけを処理できます。
必要なものとセットアップ
このスクリプトを使用するには、いくつかの準備が必要です。
ステップ1: Pythonライブラリのインストール
RARファイルの処理には rarfile ライブラリが必要です。
pip install rarfile
ステップ2: UnRAR.exe の準備([最重要])
rarfile ライブラリは、内部的に unrar コマンド(Windowsでは UnRAR.exe)を呼び出します。これはPythonライブラリとは別にインストールする必要があります。
- RarLabの公式サイト にアクセスします。
- 「UnRAR for Windows」のバイナリ(
unrar-win....exeのようなファイル)をダウンロードします。 - ダウンロードしたファイル(自己解凍書庫)を実行すると、
UnRAR.exeというファイルが展開されます。 - この
UnRAR.exeを、これから作成するPythonスクリプト(.pyファイル)と全く同じフォルダに配置してください。
(または、UnRAR.exe を C:\Windows\System32 など、システムのPATHが通っている場所に置いても構いません)
ステップ3: スクリプトの保存
以下のPythonコードを、UnRAR.exe と同じフォルダに SafeArchiveScanner.py などの名前で保存します。(コードは記事の最後にまとめて掲載します)
スクリプトの実行方法
セットアップが完了したら、コマンドプロンプトやPowerShellでスクリプトを実行します。
ケース1: 「ダウンロード」フォルダ全体をスキャン・解凍
最も簡単な使い方です。引数を何も指定せずに実行します。
python SafeArchiveScanner.py
ケース2: 特定のファイルやフォルダを指定して実行
特定のファイルや、ワイルドカード(*)を使って複数のファイルを指定することもできます。
# 特定のZIPファイルを指定
python SafeArchiveScanner.py "C:\Users\YourName\Desktop\my_file.zip"
# 特定のフォルダ内の全RARファイルを指定
python SafeArchiveScanner.py "D:\Archives\*.rar"
# 複数のファイルやフォルダを指定
python SafeArchiveScanner.py C:\temp\file1.zip D:\temp\file2.rar
オプション
--verbose: Defenderのスキャン結果(標準出力)を詳細に表示します。--dest-root [フォルダパス]: 展開先の親フォルダを指定します。(デフォルトはアーカイブと同じ場所)
スクリプトの全コード
こちらが私たちが作成したスクリプトの最終版です。
# [前回の会話で生成した SafeArchiveScanner.py の全コードをここに貼り付けます]
import argparse
import os
import sys
import glob
import subprocess
import zipfile
import rarfile # RARサポートのために追加
from pathlib import Path
from datetime import datetime
# --- rarfileのための設定 ---
# rarfileライブラリは 'unrar' (Windowsでは UnRAR.exe) コマンドが必要です。
# [https://www.rarlab.com/rar_add.htm](https://www.rarlab.com/rar_add.htm) から "UnRAR for Windows" をダウンロードし、
# UnRAR.exe をスクリプトと同じフォルダに置くか、システムのPATHが通った場所に配置してください。
# もしくは、以下のコメントアウトを解除してフルパスを指定してください。
# rarfile.UNRAR_TOOL = r"C:\path\to\UnRAR.exe"
# -------------------------
IGNORED_TOP_LEVEL = {"__MACOSX", ".DS_Store", "Thumbs.db"}
def find_defender() -> Path:
"""Return the path to MpCmdRun.exe (Microsoft Defender CLI)."""
platform_root = Path(os.environ.get("ProgramData", r"C:\\ProgramData")) / "Microsoft" / "Windows Defender" / "Platform"
if platform_root.exists():
candidates = sorted(platform_root.glob("*/MpCmdRun.exe"), key=lambda p: p.parent.name, reverse=True)
for p in candidates:
if p.is_file():
return p
legacy = Path(r"C:\\Program Files\\Windows Defender\\MpCmdRun.exe")
if legacy.exists():
return legacy
raise FileNotFoundError("MpCmdRun.exe (Microsoft Defender) が見つかりませんでした。Defenderが有効か確認してください。")
def run_defender_scan(defender: Path, target: Path) -> tuple[int, str]:
cmd = [str(defender), "-Scan", "-ScanType", "3", "-File", str(target)]
proc = subprocess.run(cmd, capture_output=True, text=True, shell=False)
stdout = (proc.stdout or "") + ("\n" + proc.stderr if proc.stderr else "")
return proc.returncode, stdout
def is_encrypted_archive(archive_path: Path) -> bool:
"""ZIPまたはRARファイルが暗号化されているかチェックする"""
suffix = archive_path.suffix.lower()
if suffix == ".zip":
try:
with zipfile.ZipFile(archive_path) as zf:
# 1つでも暗号化フラグが立っているかチェック
return any((zi.flag_bits & 0x1) == 0x1 for zi in zf.infolist())
except zipfile.BadZipFile:
return False # 壊れたファイルは暗号化扱いしない
elif suffix == ".rar":
try:
with rarfile.RarFile(archive_path) as rf:
# needs_password() はRAR5で信頼できる。RAR4以前はinfolistをチェック
if rf.needs_password():
return True
# ヘッダ暗号化(needs_password()がTrue)でない場合、ファイル単位の暗号化をチェック
return any(zi.needs_password() for zi in rf.infolist())
except rarfile.BadRarFile:
return False # 壊れたファイル
except rarfile.RarCannotExec as e:
# unrarが見つからない場合、この例外が発生する
# この例外を捕捉し、上位のmain関数に処理を任せる
raise e
return False
def safe_extract(archive_path: Path, dest_dir: Path) -> Path:
"""Safely extract ZIP/RAR contents to dest_dir, preventing Zip Slip.
Returns the extraction directory path.
"""
dest_dir.mkdir(parents=True, exist_ok=True)
suffix = archive_path.suffix.lower()
members = []
opener = None
is_dir_check = None
filename_attr = None
closer = None
if suffix == ".zip":
zf = zipfile.ZipFile(archive_path)
members = zf.infolist()
opener = lambda member: zf.open(member, 'r')
is_dir_check = lambda member: member.is_dir()
filename_attr = "filename"
closer = lambda: zf.close()
elif suffix == ".rar":
try:
rf = rarfile.RarFile(archive_path)
except rarfile.RarCannotExec as e:
# unrar実行ファイルが見つからない場合のエラー
print(f"[ERROR] 'unrar' 実行ファイルが見つかりません: {e}", file=sys.stderr)
print("rarfileライブラリを使用するには 'UnRAR.exe' が必要です。", file=sys.stderr)
print("スクリプト冒頭のコメントを参照してセットアップしてください。", file=sys.stderr)
raise # エラーを上位に投げて、このファイルの処理を中止させる
members = rf.infolist()
opener = lambda member: rf.open(member, 'r')
is_dir_check = lambda member: member.isdir()
filename_attr = "filename"
closer = lambda: rf.close()
else:
raise ValueError(f"未対応のアーカイブ形式です: {suffix}")
try:
for member in members:
filename = getattr(member, filename_attr)
member_path = Path(filename)
# Windows/Linuxのパス区切り文字を正規化
# rarfileは'\'、zipfileは'/'を使うことがあるため
parts = []
current = member_path
while current and current != current.parent:
parts.append(current.name)
current = current.parent
if parts:
member_path = Path(parts.pop()).joinpath(*reversed(parts))
if member_path.is_absolute() or ":" in str(member_path):
raise RuntimeError(f"危険なパスを検出: {filename}")
resolved = (dest_dir / member_path).resolve()
if dest_dir.resolve() not in resolved.parents and dest_dir.resolve() != resolved:
raise RuntimeError(f"展開先を逸脱するパスを検出: {filename}")
if is_dir_check(member):
resolved.mkdir(parents=True, exist_ok=True)
else:
resolved.parent.mkdir(parents=True, exist_ok=True)
with opener(member) as src, open(resolved, 'wb') as dst:
# 大きなファイルのためにチャンクで読み書き
chunk = src.read(8192)
while chunk:
dst.write(chunk)
chunk = src.read(8192)
finally:
if closer:
closer() # zipfileまたはrarfileをクローズ
return dest_dir
def simplify_single_file(dest_dir: Path):
"""If the extracted tree contains exactly one file total, move it to parent and remove the tree."""
all_items = list(dest_dir.rglob("*"))
files = [p for p in all_items if p.is_file()]
if len(files) == 1:
single_file = files[0]
target_path = dest_dir.parent / single_file.name
try:
if target_path.exists():
ts = datetime.now().strftime("%Y%m%d_%H%M%S")
target_path = target_path.with_name(f"{target_path.stem}_{ts}{target_path.suffix}")
single_file.rename(target_path)
# remove extracted dir if now empty
try:
for root, dirs, f in os.walk(dest_dir, topdown=False):
for name in f:
try:
os.remove(Path(root)/name)
except Exception:
pass
for name in dirs:
try:
os.rmdir(Path(root)/name)
except Exception:
pass
os.rmdir(dest_dir)
except Exception as e:
print(f"[WARN] 一時展開ディレクトリ削除に失敗: {e}")
print(f"[SIMPLIFY] 単一ファイルを抽出しました → {target_path}")
except Exception as e:
print(f"[WARN] 単一ファイル抽出に失敗: {e}")
def flatten_single_top_folder(extracted_dir: Path) -> Path | None:
"""If the extracted dir has exactly one top-level folder (ignoring common junk),
move that folder up beside the ZIP (to the parent of extracted_dir) and remove the wrapper.
Returns the new top-level path if flattened, else None.
"""
try:
children = [p for p in extracted_dir.iterdir() if p.name not in IGNORED_TOP_LEVEL]
except FileNotFoundError:
return None
if len(children) == 1 and children[0].is_dir():
inner = children[0]
target = extracted_dir.parent / inner.name
if target.exists():
ts = datetime.now().strftime("%Y%m%d_%H%M%S")
target = extracted_dir.parent / f"{inner.name}_{ts}"
try:
inner.rename(target)
# remove the now-empty wrapper dir
try:
os.rmdir(extracted_dir)
except OSError:
pass
print(f"[SIMPLIFY] 単一フォルダーを上位へ移動しました → {target}")
return target
except Exception as e:
print(f"[WARN] 単一フォルダーの移動に失敗: {e}")
return None
return None
def find_downloads_dir() -> Path:
home = Path(os.environ.get("USERPROFILE", str(Path.home())))
downloads = home / "Downloads"
return downloads
def iter_archive_targets(paths: list[Path]) -> list[Path]:
"""対象の .zip と .rar を検索する"""
targets: list[Path] = []
supported_suffixes = {".zip", ".rar"}
for p in paths:
if p.is_dir():
for suffix in supported_suffixes:
targets.extend(sorted(Path(p).glob(f"*{suffix}")))
elif p.is_file() and p.suffix.lower() in supported_suffixes:
targets.append(p)
else:
# ワイルドカード展開を試みる
for g in glob.glob(str(p)):
gp = Path(g)
if gp.is_file() and gp.suffix.lower() in supported_suffixes:
targets.append(gp)
seen = set()
unique: list[Path] = []
for t in targets:
if t not in seen:
unique.append(t)
seen.add(t)
return unique
def main():
parser = argparse.ArgumentParser(description="Windows DefenderでZIP/RARをスキャンし、安全なら解凍する")
parser.add_argument("paths", nargs="*", type=Path, help="スキャン対象。省略時はダウンロードフォルダ内の*.zip, *.rar")
parser.add_argument("--dest-root", type=Path, default=None, help="展開先の親ディレクトリ(省略時は各アーカイブと同じ場所)")
parser.add_argument("--verbose", action="store_true", help="Defender出力を表示する")
args = parser.parse_args()
if args.paths:
candidates = iter_archive_targets(args.paths)
else:
downloads = find_downloads_dir()
candidates = iter_archive_targets([downloads])
if not candidates:
print("ZIPまたはRARファイルが見つかりませんでした。", file=sys.stderr)
sys.exit(1)
try:
defender = find_defender()
except FileNotFoundError as e:
print(str(e), file=sys.stderr)
sys.exit(2)
print(f"[INFO] 使用するDefender: {defender}")
print(f"[INFO] 対象アーカイブ数: {len(candidates)}\n")
any_infected = False
for archive_path in candidates:
print(f"=== {archive_path} ===")
try:
if is_encrypted_archive(archive_path):
print("[SKIP] パスワード付きアーカイブは未対応(安全のため手動で確認してください)")
continue
except (zipfile.BadZipFile, rarfile.BadRarFile):
print("[ERROR] 壊れたアーカイブです。スキャン前にスキップします")
continue
except rarfile.RarCannotExec as e:
# is_encrypted_archive で 'unrar' が見つからない場合
print(f"[ERROR] 'unrar' 実行ファイルエラー: {e}")
print("[ERROR] スクリプト冒頭のコメントを参照して 'UnRAR.exe' をセットアップしてください。")
print("[ERROR] このファイルの処理をスキップします。")
continue
rc, out = run_defender_scan(defender, archive_path)
if args.verbose:
print("--- Defender 出力 ---")
print(out.strip())
print("----------------------")
if rc == 0:
print("[OK] スキャン合格(脅威なし)")
elif rc == 2:
print("[ALERT] 脅威を検出。展開を中止します")
any_infected = True
continue
else:
print(f"[WARN] 予期しない終了コード {rc}。出力を確認してください。展開は中止します。")
any_infected = True
continue
dest_parent = args.dest_root if args.dest_root else archive_path.parent
dest_dir = dest_parent / archive_path.stem
final_dest = dest_dir
if final_dest.exists():
ts = datetime.now().strftime("%Y%m%d_%H%M%S")
final_dest = dest_parent / f"{archive_path.stem}_{ts}"
try:
extracted_dir = safe_extract(archive_path, final_dest)
# 1) 単一トップフォルダーだけなら、それを親へ移動
flattened = flatten_single_top_folder(extracted_dir)
top_after = flattened if flattened else extracted_dir
# 2) 全体でファイルが1つだけなら、そのファイルのみ取り出す
if not flattened:
simplify_single_file(top_after)
print(f"[DONE] 展開完了 → {top_after if flattened else final_dest}")
# 解凍完了後にアーカイブ削除
try:
archive_path.unlink()
print("[CLEANUP] アーカイブを削除しました")
except Exception as e:
print(f"[WARN] アーカイブ削除に失敗: {e}")
except Exception as e:
print(f"[ERROR] 展開失敗: {e}")
if isinstance(e, rarfile.RarCannotExec):
# safe_extract内で発生した場合 (is_encrypted_archiveを通過した場合など)
print("[ERROR] 'unrar' 実行ファイルが見つかりません。スクリプト冒頭のコメントを参照してください。")
print()
if any_infected:
sys.exit(3)
if __name__ == "__main__":
main()
注意点
- パスワード付きアーカイブは対象外: 暗号化されたZIP/RARは、スキャンも展開もできません。これらは「スキップ」されますので、手動で確認してください。
UnRAR.exeが必須: RARの処理にはUnRAR.exeが不可欠です。セットアップを忘れると、RARファイルの処理時にエラーが発生します。- 自己責任で: このスクリプトはアーカイブファイルを自動で削除します。必ずテスト用のファイルで動作を確認してから、実際のファイルに使用してください。
まとめ
これで、ダウンロードしたアーカイブファイルを扱う際の「スキャン」と「解凍」という面倒な(しかし重要な)プロセスを自動化することができました。
このような日々の小さな「面倒」をPythonで解決していくのは、とても楽しいですね。ぜひ活用してみてください。


コメント