春江暮客

春江暮客的个人学习分享网站

用 Python 查找重复文件:大小筛选、分块 SHA-256 与只读报告

2026-10-27 技术
用 Python 查找重复文件:大小筛选、分块 SHA-256 与只读报告

下载目录、备份副本和批处理输出越积越多,文件名不同,内容却可能一样。只比较大小会把不同文件混在一起;把整个文件读入内存,又容易拖垮大文件检查。

本文写一个只读的重复文件报告工具:先按字节数筛选,再分块计算 SHA-256,输出 JSON。它不会删除、移动或改写被检查的文件。适合已经会运行终端命令的初级到中级读者;若还不知道该检查哪个目录,可以先用大文件排行工具缩小范围。

1. 方法:大小筛选,再计算内容摘要

  1. 遍历本地目录,包括隐藏文件。通过 lstat() 与普通文件类型判断跳过符号链接和 FIFO 等特殊文件。
  2. 按 (st_dev, st_ino) 保留每个实际文件的一个路径,避免把硬链接当成独立副本;这两个字段的含义见 Python 的 stat 结果说明。
  3. 把普通文件按 st_size 分组。只有包含两个以上独立文件的大小分组才需要读取内容。
  4. 每次读取 1 MiB,累加 SHA-256;同大小、同摘要的文件进入一个候选重复组。

这里比较的是原始字节。换行符、编码或 CSV 行顺序不同,都会影响结果。空文件也会形成重复组。摘要分组用于筛选;需要严格确认时,再对候选文件逐字节比较。

2. 准备环境与样本

使用 Linux、Python 3.9 或更新版本,以及 Bash 或 Zsh。只用标准库,无须安装第三方包。先在一个新的演示目录中执行:

python3 --version
mkdir duplicate-demo
cd duplicate-demo
python3 - <<'PY_DATA'
from pathlib import Path
root = Path("data")
root.mkdir()
(root / "a.txt").write_bytes(b"alpha\n")
(root / "b.txt").write_bytes(b"alpha\n")
(root / "c.txt").write_bytes(b"bravo\n")
(root / "unique.txt").write_bytes(b"one longer unique record\n")
(root / "empty-a").write_bytes(b"")
(root / "empty-b").write_bytes(b"")
PY_DATA

a.txt 和 b.txt 内容相同;c.txt 与它们同为 6 字节,但内容不同。两个空文件用于验证零字节分组。后面的脚本和报告都放在 data 外面,避免把报告本身扫进去。重复实验时换一个新目录。

3. 完整实现

将下面代码保存为当前 duplicate-demo 目录中的 find_duplicates.py:

"""Find equal-size, equal-SHA-256 file groups. Linux, Python 3.9+."""
import argparse
from collections import defaultdict
import hashlib
import json
import os
from pathlib import Path
import stat
import sys


def sha256_file(path):
    digest = hashlib.sha256()
    with path.open("rb") as handle:
        while True:
            block = handle.read(1024 * 1024)
            if not block:
                break
            digest.update(block)
    return digest.hexdigest()


def find_groups(root):
    def fail(error):
        raise error

    sizes = defaultdict(list)
    seen = set()
    for folder, dirs, names in os.walk(root, onerror=fail, followlinks=False):
        dirs.sort()
        for name in sorted(names):
            path = Path(folder) / name
            info = path.lstat()
            if not stat.S_ISREG(info.st_mode):
                continue
            identity = (info.st_dev, info.st_ino)
            if identity in seen:
                continue
            seen.add(identity)
            sizes[info.st_size].append(path)

    groups = []
    for size, paths in sorted(sizes.items()):
        if len(paths) < 2:
            continue
        hashes = defaultdict(list)
        for path in paths:
            hashes[sha256_file(path)].append(path.relative_to(root).as_posix())
        for digest, matches in sorted(hashes.items()):
            if len(matches) > 1:
                groups.append({"size_bytes": size, "sha256": digest,
                               "files": sorted(matches)})
    return groups


def main():
    parser = argparse.ArgumentParser(description=__doc__, allow_abbrev=False)
    parser.add_argument("directory", type=Path, help="quiet local directory to scan")
    args = parser.parse_args()
    try:
        if args.directory.is_symlink():
            raise ValueError("scan root must not be a symlink")
        root = args.directory.resolve(strict=True)
        if not root.is_dir():
            raise ValueError("scan root must be a directory")
        groups = find_groups(root)
    except (OSError, ValueError) as exc:
        print(f"ERROR: {exc}", file=sys.stderr)
        return 1
    print(json.dumps({"groups": groups}, ensure_ascii=True, indent=2))
    return 0


if __name__ == "__main__":
    raise SystemExit(main())

os.walk() 的官方文档说明,目录遍历错误默认会被忽略。这里通过 onerror=fail 立即报错,避免把漏扫目录解释成“没有重复文件”;followlinks=False 避免进入链接目录。最终文件条目还要检查类型,才能排除文件符号链接和断链。

hashlib.update()支持连续输入字节块,效果等同于一次输入拼接后的内容。代码不保存整个文件内容;路径、inode 集合与分组仍占用随文件数量增长的内存,因此它并不是常量总内存算法。

扫描顺序按目录名和文件名排序;同一硬链接文件保留首先遇到的路径。结果按大小、摘要排序,每组路径再次排序。JSON 使用 ASCII 转义,中文文件名会显示为 \u...,解析后仍是原文件名,也便于保存包含特殊字符的路径。

4. 运行并验证报告

python3 find_duplicates.py --help
python3 find_duplicates.py data > report.json
python3 - <<'PY_CHECK'
import json
from pathlib import Path
report = json.loads(Path("report.json").read_text(encoding="utf-8"))
assert [g["files"] for g in report["groups"]] == [
    ["empty-a", "empty-b"], ["a.txt", "b.txt"]
]
assert [g["size_bytes"] for g in report["groups"]] == [0, 6]
print("OK: two groups; same-size c.txt excluded")
PY_CHECK

预期输出:

OK: two groups; same-size c.txt excluded

报告包含 groups 数组,每组都有 size_bytes、sha256 和相对路径列表 files。没有候选重复时是 {"groups": []}。退出码 0 表示扫描完成,不表示目录内没有重复文件;退出码 1 表示扫描或读取失败,此时标准输出没有 JSON;命令参数错误由 argparse 返回 2。

若要记录整套输入在两次运行之间是否变化,可以使用SHA-256 文件清单。它核对路径及版本;本文则在一次扫描中寻找内容相同的候选副本。

5. 验证硬链接、符号链接和失败分支

在同一个新演示目录中执行一次下面的检查。它只对 data 内的演示文件创建链接与 FIFO:

python3 - <<'PY_LINKS'
import json
import os
from pathlib import Path
import subprocess
import sys

root = Path("data")
os.link(root / "a.txt", root / "z-hardlink.txt")
(root / "file-link").symlink_to("a.txt")
(root / "broken-link").symlink_to("missing.txt")
(root / "loop").symlink_to(".", target_is_directory=True)
os.mkfifo(root / "pipe")
result = subprocess.run([sys.executable, "find_duplicates.py", "data"],
                        capture_output=True, text=True, check=True)
report = json.loads(result.stdout)
assert [g["files"] for g in report["groups"]] == [
    ["empty-a", "empty-b"], ["a.txt", "b.txt"]
]
failed = subprocess.run([sys.executable, "find_duplicates.py", "missing-dir"],
                        capture_output=True, text=True, check=False)
assert failed.returncode == 1 and failed.stdout == ""
assert "ERROR:" in failed.stderr
print("OK: hardlinks, symlinks, FIFO, missing directory")
PY_LINKS

预期输出:

OK: hardlinks, symlinks, FIFO, missing directory

z-hardlink.txt 与 a.txt 指向同一 inode,不能据此算出一份可回收副本。文件符号链接、断链、目录循环链接和 FIFO 都不会进入报告。需要另行排查链接目标时,可用失效符号链接检查工具。

Shell 重定向会在 Python 启动前创建或清空报告,所以要先确认退出状态,再读取结果:

if python3 find_duplicates.py missing-dir > failed-report.json; then
  printf 'Unexpected success\n'
else
  printf 'Scan failed; do not use failed-report.json\n'
fi

这个失败示例会留下空的 failed-report.json,不能把它当作有效空报告。正式使用时让调用方只在命令成功后接收结果,并把报告保存在扫描目录之外。

6. 排错与使用边界

现象 具体处理
Permission denied 用运行任务的同一账户检查文件与父目录权限,选择有权读取的目录后重试;不要忽略报错继续使用结果。
No such file 检查根路径;若文件在扫描中被移动,先暂停写入或使用稳定快照,再运行。
scan root must not be a symlink 传入实际目录路径。这个检查针对根路径本身,父路径中的链接仍可能被解析。
大目录读取很慢 先缩小扫描范围。文件大小大量相同时,仍须读取这些候选文件的全部内容。
空文件也出现在组中 这是预期行为,size_bytes: 0 表示没有文件内容字节可节省。
第二次链接测试提示已存在 使用新的演示目录重新执行,不要为了重跑而删除真实数据。

先停止写入,再扫描。 这个脚本不是文件系统快照;扫描与读取之间的并发变化可能导致漏报、误分组或报错,也不用于处理恶意替换路径。读取还可能触发文件系统访问时间更新。

相同摘要不证明文件可以删除:备份职责、文件权限、稀疏文件、压缩、写时复制和硬链接都会影响保留策略或实际占用。本工具不估算可回收空间;删除前需要按业务确认,并对候选重新进行字节比较。

7. 小结

先按大小排除不可能相同的文件,再分块计算摘要,可以减少不必要的读取,并避免把大文件整体放进内存。先用同大小不同内容、硬链接和失败路径验证工具,再对稳定目录生成报告。将候选重复组留给人工或后续业务流程判断,保留完整报告与退出状态。

标签

1024 12306 ablang adsense agents.md ai ai-agent ai-agents ai-seo algorithm amp antibodies apparmor argparse automation backup bash batch-processing binarycif bioinformatics biopython blockchain boltz bootstrapping boxes bubblewrap c-index cca cdn chatgpt checkpoint cli cloudflare codex cofoldarena concurrency configuration copy cprofile cpu监控 cron csv cuda curl data-leakage data-processing data-quality data-validation datascience datavisualization datetime debugging decimal deployment desktop-app devtools disk-space disown dns docker dovecot download electron encoding esm esm2 esm3 esmc esmfold2 faceswap fasta fastmcp faulthandler ffmpeg file-descriptors file-io files flashppi flask folium frontend game generator git github-actions google google-research grep gzip harness hls html http hugo imports indexnow javascript jev json jsonl just k-means kaggle langfuse leecode linux list litellm llm llms.txt logging logs lollipop lsof m3u8 machine-learning macos manacher matplotlib mcp memory mirror mmcif model-evaluation monitoring mp3 mp4 mpnn multiomics mutation mysql nanobert nanobodies networkx nginx normalize numpy ollama omegatherm pandas password pathlib pdb pep-723 performance phaser pillow pip postfix preprocessing print protein-alignment protein-design protein-embeddings protein-interactions protein-language-models protein-stability protein-structure proxy pydantic pyecharts pyqt python python3 r raincloud reproducibility requests reservoir-sampling rfoptimization rg ripgrep rosettafold3 roundcube rrsi rsi rsync s-tui sampling scale scikit-learn scrapy screen seaborn security selenium seo sequence-identity sha256 shell signals sklearn socket solana somaticsignatures spl sqlite ssh standardize static-site subprocess symlink sysadmin system-one tarfile tcp tempfile tensorflow tkinter tls toml tracemalloc tron tronpy troubleshooting turtle typesafe-ai ubuntu unicode urllib usdt utf-8 uv vhhbert vite webp wordcloud wordpress workflow yaml zipfile zoneinfo 后台 寓言 概率 经济 贸易 迅雷解析 钱包

友情链接

其它