办公效率

给批量文件脚本加入预览和日志:建立可检查的办公自动化流程

AI智能摘要

批量整理文件最怕的不是步骤繁琐,而是误操作后难以追查去向。文章以按扩展名归类为例,介绍如何让 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. 常见问题与使用限制

  • 预览也会产生文件吗? 会。脚本会创建日志目录和本次运行的日志、操作清单,但预览模式不会移动源文件。
  • 为什么有些文件被跳过? 目标位置已有同名文件,或本次计划中出现相同目标路径时,脚本会跳过,避免覆盖。
  • 失败重试后仍不成功怎么办? 查看错误记录,检查文件是否被其他程序占用、路径是否可用,再决定是否重新运行。不要盲目增加重试次数。
  • 能否用来处理子目录? 当前版本只处理源目录第一层文件。要递归处理,应先明确子目录映射和同名文件规则,再修改脚本并重新预览。
  • 日志能否替代备份? 不能。日志能帮助你追踪脚本记录的操作,但不保存文件内容,也无法保证所有外部变化都被记录。重要数据仍应先备份。

让批量脚本从“能运行”变成“可复核”,关键不是增加复杂功能,而是默认预览、执行前确认、逐项记录,并为失败和恢复留出清晰路径。

热门话题

52okp 是一名关注人工智能、开源软件与效率工具的技术内容创作者,长期实践 Stable Diffusion、ComfyUI、AI 智能体、MCP、Codex 和各类开源项目。通过实际安装、配置与测试,整理可复现的操作教程、问题排查方法和工具使用经验。

登录用户才能发表评论! 登录账户

取消回复

评论列表 (3条):

加载更多评论 Loading...

延伸阅读:

在 VS Code 中搭建 Python 办公自动化环境:从解释器选择到依赖安装

文章以 VS Code 和 Python 为基础,讲解办公自动化项目的完整环境搭建流程:准备编辑器、解释器与 Pytho...

52okp
2026-09-22

用 Python 批量处理 PDF 文件:提取文本、按规则命名并检查异常文件

面对合同、发票和资料 PDF,逐个打开、改名、分拣既耗时又容易漏掉异常。借助 Python 与开源 pypdf,可批量提...

52okp
2026-09-24

用 Python 按模板生成办公报告:从 Excel 数据到 Word 文档的自动填充

每周把 Excel 项目进度、负责人和日期重复填进 Word 周报,既费时也容易漏项;Python 可以按模板批量生成文...

52okp
2026-09-26

整理扫描件前先做什么:用 OCR 结果辅助文件命名和人工复核

整理扫描合同、票据时,逐份打开录入耗时,直接照搬 OCR 结果命名又可能把日期、金额或编号认错,甚至让敏感原件进入不合适...

52okp
2026-09-27
    返回顶部