批量整理文件最怕的不是步骤繁琐,而是误操作后难以追查去向。文章以按扩展名归类为例,介绍如何让 Python 脚本默认只预览,只有显式启用执行并确认后才移动源目录第一层的文件;同时记录逐项操作、冲突和失败,方便复核与恢复。无需额外安装依赖,这套带日志和确认开关的流程,如何让办公自动化变得更可检查?
— 此摘要由AI分析文章内容生成,仅供参考。
如果你已经会运行 Python 文件脚本,但担心批量操作误改文件,可以给脚本加上预览模式、逐项操作日志、失败记录和明确的确认开关。下面用“按扩展名归类文件”做例子:默认只预览;只有显式启用执行并确认后,脚本才会移动文件,同时记录每项操作,方便复核和恢复。

1. 准备脚本和目录
示例只处理源目录第一层的普通文件,不递归进入子目录。它会把文件移动到输出目录下按扩展名命名的子目录,例如 report.pdf 会归入 output/pdf/。无扩展名文件会归入 output/无扩展名/。
脚本使用 Python 标准库,不需要额外安装依赖。将下面代码保存为 organize_files.py:
import argparse
import json
import logging
import shutil
import sys
import time
from datetime import datetime
from pathlib import Path
def write_event(manifest, **event):
"""向 JSONL 操作清单追加一条记录。"""
event["time"] = datetime.now().isoformat(timespec="seconds")
with manifest.open("a", encoding="utf-8") as f:
f.write(json.dumps(event, ensure_ascii=False) + "n")
def main():
parser = argparse.ArgumentParser(
description="按扩展名整理文件;默认只预览,不移动文件。"
)
parser.add_argument("source", help="待整理的源目录")
parser.add_argument("output", help="整理后的输出目录")
parser.add_argument(
"--apply",
action="store_true",
help="实际执行移动;不添加此参数时只预览",
)
parser.add_argument(
"--yes",
action="store_true",
help="跳过交互确认;必须与 --apply 一起使用",
)
parser.add_argument(
"--log-dir",
default="run_logs",
help="日志目录,默认是当前目录下的 run_logs",
)
args = parser.parse_args()
source = Path(args.source).expanduser().resolve()
output = Path(args.output).expanduser().resolve()
log_dir = Path(args.log_dir).expanduser().resolve()
if not source.is_dir():
parser.error(f"源目录不存在或不是目录:{source}")
if source == output:
parser.error("输出目录不能与源目录相同。")
if args.yes and not args.apply:
parser.error("--yes 必须与 --apply 一起使用。")
log_dir.mkdir(parents=True, exist_ok=True)
run_id = datetime.now().strftime("%Y%m%d_%H%M%S")
log_file = log_dir / f"run_{run_id}.log"
manifest = log_dir / f"run_{run_id}.jsonl"
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s %(levelname)s %(message)s",
handlers=[
logging.FileHandler(log_file, encoding="utf-8"),
logging.StreamHandler(sys.stdout),
],
)
logger = logging.getLogger(__name__)
# 仅收集源目录第一层的文件,并跳过符号链接。
files = sorted(
p for p in source.iterdir()
if p.is_file() and not p.is_symlink()
)
plans = []
reserved_targets = set()
for src in files:
extension = src.suffix.lower().lstrip(".") or "无扩展名"
target = output / extension / src.name
# 不覆盖已有文件,也避免本次计划中的目标路径冲突。
if target.exists() or target in reserved_targets:
logger.warning("跳过目标冲突:%s -> %s", src, target)
write_event(
manifest,
status="skipped_conflict",
source=str(src),
target=str(target),
)
continue
reserved_targets.add(target)
plans.append((src, target))
logger.info("源目录:%s", source)
logger.info("输出目录:%s", output)
logger.info("本次计划处理 %d 个文件。", len(plans))
for src, target in plans:
logger.info("计划:%s -> %s", src, target)
write_event(
manifest,
status="planned",
source=str(src),
target=str(target),
)
if not args.apply:
logger.info("当前为预览模式,未移动文件。要执行请添加 --apply。")
logger.info("日志:%s;操作清单:%s", log_file, manifest)
return
if not args.yes:
answer = input("确认按以上计划移动文件?请输入 YES:")
if answer.strip() != "YES":
logger.info("用户取消,未执行移动。")
return
for src, target in plans:
# 执行前再次检查,避免处理预览后已变化的路径。
if not src.exists():
logger.error("源文件已不存在,跳过:%s", src)
write_event(
manifest,
status="failed",
source=str(src),
target=str(target),
error="源文件已不存在",
)
continue
if target.exists():
logger.error("目标文件已存在,跳过:%s", target)
write_event(
manifest,
status="skipped_conflict",
source=str(src),
target=str(target),
)
continue
try:
target.parent.mkdir(parents=True, exist_ok=True)
except OSError as exc:
logger.exception("创建输出目录失败:%s", target.parent)
write_event(
manifest,
status="failed",
source=str(src),
target=str(target),
error=str(exc),
)
continue
# 仅对 OSError 做有限重试;每次重试前检查源和目标状态。
for attempt in range(1, 4):
try:
if not src.exists():
raise FileNotFoundError(f"源文件已不存在:{src}")
if target.exists():
raise FileExistsError(f"目标文件已存在:{target}")
shutil.move(str(src), str(target))
logger.info("已移动:%s -> %s", src, target)
write_event(
manifest,
status="moved",
source=str(src),
target=str(target),
)
break
except OSError as exc:
# 发生异常后先检查状态,避免对已完成的移动重复操作。
if not src.exists() and target.exists():
logger.warning("移动后状态显示目标已就位:%s", target)
write_event(
manifest,
status="moved",
source=str(src),
target=str(target),
note="异常后检查发现源已移除、目标已存在",
)
break
if attempt < 3 and src.exists() and not target.exists():
wait_seconds = attempt
logger.warning(
"操作失败,将在 %d 秒后重试(%d/3):%s",
wait_seconds,
attempt,
exc,
)
time.sleep(wait_seconds)
else:
logger.exception("操作失败,不再重试:%s", src)
write_event(
manifest,
status="failed",
source=str(src),
target=str(target),
error=str(exc),
)
break
logger.info("处理结束。日志:%s;操作清单:%s", log_file, manifest)
if __name__ == "__main__":
main()
脚本会在日志目录中生成两个文件:.log 便于直接阅读,.jsonl 则按行保存结构化操作记录。每条记录包含状态、源路径和目标路径;失败时还会记录错误信息。预览也会写入计划记录,因此你可以检查脚本准备处理哪些文件,而不必先移动它们。
2. 先预览,再确认执行
把 待整理 换成实际源目录,把 整理结果 换成你希望存放归类文件的位置。
预览,不移动文件:
python organize_files.py "./待整理" "./整理结果"
检查终端输出和 run_logs 中的日志、操作清单。确认源目录、目标目录以及每条“计划”都符合预期后,再执行:
python organize_files.py "./待整理" "./整理结果" --apply
执行时,脚本还会要求你输入大写的 YES。如果你已经通过其他方式完成检查,确实需要非交互执行,可以使用:
python organize_files.py "./待整理" "./整理结果" --apply --yes
--yes 不是预览开关,也不能单独启用执行;只有同时传入 --apply 才有效。日常操作建议先预览,并保留交互确认。

3. 如何检查日志与失败重试
查看日志时,重点核对以下信息:
计划:脚本预计处理的源文件和目标路径。已移动:脚本记录为完成的移动操作。跳过目标冲突:目标位置已有同名文件,脚本选择跳过而不覆盖。操作失败:执行遇到异常,错误会同时写入日志和 JSONL 清单。
脚本对文件移动失败最多尝试三次,等待时间依次为 1 秒和 2 秒。重试适合应对偶发的文件占用等问题,但无法解决持续存在的权限、路径或磁盘故障。失败后先检查清单中的 source、target 和 error,确认原因,再手动处理或重新运行。
重新运行前要先看清单:已经移动的文件通常已不在源目录,脚本不会再次处理它们;如果目标文件已存在,则会跳过。不要在同一批目录上同时启动多个实例,否则预览后的状态可能在执行前发生变化。
4. 输出隔离与恢复步骤
把输出目录设为单独位置,不要直接覆盖或混入需要长期保留的原始资料。执行前也可以复制一份源目录,尤其是文件价值较高或移动结果不容易重新生成时。这个脚本只处理源目录第一层文件,不会整理子目录中的内容。
如果需要撤销已成功的移动,可以根据 JSONL 中 status 为 moved 的记录,按相反方向把目标文件移回原路径。操作前先检查原路径是否已被占用;如果已存在同名文件,不要覆盖,应先人工比较内容并决定如何处理。
下面是一个谨慎的恢复示例:它从指定的操作清单中逆序读取成功记录;只有目标文件存在、原路径空闲时才会移动。将 操作清单.jsonl 替换为实际清单路径后运行:
import json
import shutil
from pathlib import Path
manifest = Path("操作清单.jsonl")
events = []
with manifest.open("r", encoding="utf-8") as f:
for line in f:
if line.strip():
events.append(json.loads(line))
for event in reversed(events):
if event.get("status") != "moved":
continue
original = Path(event["source"])
moved_to = Path(event["target"])
if not moved_to.exists():
print(f"跳过:目标文件不存在:{moved_to}")
continue
if original.exists():
print(f"跳过:原路径已被占用:{original}")
continue
original.parent.mkdir(parents=True, exist_ok=True)
shutil.move(str(moved_to), str(original))
print(f"已恢复:{moved_to} -> {original}")
如果曾在异常后发现源文件已移除、目标文件已存在,清单中的状态也可能记录为 moved。因此恢复前仍要逐项检查实际文件,不要只依据日志自动批量回滚。
5. 常见问题与使用限制
- 预览也会产生文件吗? 会。脚本会创建日志目录和本次运行的日志、操作清单,但预览模式不会移动源文件。
- 为什么有些文件被跳过? 目标位置已有同名文件,或本次计划中出现相同目标路径时,脚本会跳过,避免覆盖。
- 失败重试后仍不成功怎么办? 查看错误记录,检查文件是否被其他程序占用、路径是否可用,再决定是否重新运行。不要盲目增加重试次数。
- 能否用来处理子目录? 当前版本只处理源目录第一层文件。要递归处理,应先明确子目录映射和同名文件规则,再修改脚本并重新预览。
- 日志能否替代备份? 不能。日志能帮助你追踪脚本记录的操作,但不保存文件内容,也无法保证所有外部变化都被记录。重要数据仍应先备份。
让批量脚本从“能运行”变成“可复核”,关键不是增加复杂功能,而是默认预览、执行前确认、逐项记录,并为失败和恢复留出清晰路径。

评论列表 (3条):
加载更多评论 Loading...