SmartArchiver 是一个灵活的文件归档与清理工具,帮助你按规则自动管理磁盘上的文件——将旧文件移动到归档目录、清理过期日志、按条件轮转文件、或保持两个目录的镜像同步。
📋 TODO 看板
SmartArchiver 解决的核心问题是:"磁盘上有一堆文件,我想根据名字、大小、时间等条件,把它们搬运、删除或同步到别处"。它既可作为一次性脚本,也可作为常驻服务定时运行。以下是几个典型场景:
- 冷热数据分层:将 SSD 上超过 N 天未修改的文件自动迁移到 HDD 大容量存储,同时保留最近文件在高速盘上。
- 日志归档与清理:将
logs/目录下超过一定天数的大日志文件移动到归档目录;或当日志总体积超过阈值时,从最旧的开始轮转删除。 - 备份目录瘦身:在备份目录中保留最近 N 份全量备份或限制总大小,超出部分自动清除。
- 选择性同步:将源目录镜像同步到目标,但排除临时文件、缓存目录等不需要同步的内容。
- 白名单模式:仅处理符合特定命名规则的文件(如只搬运
*.mp4),其他一律不动。 - 定时自动运行:通过 cron 表达式或固定间隔,让程序在后台持续执行上述任务。
- 跨主机远程操作:将文件归档到另一台主机(HTTP 远端),或通过 SSH 将目录镜像同步到远端服务器。
需要 Python 3.11+(内置 tomllib,无需额外安装 TOML 解析库)。
# 1. 克隆项目
git clone https://github.com/hexstan/smart-archiver.git
cd smart-archiver
# 2. 安装依赖
pip install -r requirements.txt
# 3. 创建并编辑配置文件
cp config/config.example.toml config/config.toml
# 按需编辑 config/config.toml,参考示例文件中的注释
# 4. 一次性运行(客户端模式)
python main.py如需使用同步(sync)模式:
- Linux:需安装
rsync(apt install rsync/yum install rsync) - Windows:需安装
rclone并将其加入 PATH - 可通过
tool配置项强制指定工具("rsync"或"rclone")
SmartArchiver 可作为 HTTP 服务器运行,接受来自其他客户端实例的远程文件操作请求。
# 1. 创建服务器配置文件
cp config/config.server.example.toml config/config.server.toml
# 编辑 api_key(API 认证密钥)、port(监听端口)等参数
# 2. 启动服务器
python main.py --server服务器提供两个 API 端点:POST /api/remote(元数据操作)和 POST /api/remote/upload(文件上传),支持并发控制和排队机制。详细配置见 config/config.server.example.toml。
项目为三种角色提供了独立的镜像:客户端、HTTP 服务器和 SSH 远端服务器。
应用镜像基于 python:3.14-slim-trixie,已内置 rsync。GitHub Actions 自动构建 linux/amd64 和 linux/arm64 多架构镜像,发布到 ghcr.io/hexstan/smart-archiver。
# 1. 创建并编辑配置文件
cp config/config.example.toml config/config.toml
# 2. 按需编辑 docker-compose.yml 中的目录挂载映射
# 3. 启动客户端容器
docker-compose up -d
# 查看日志
docker-compose logs -fdocker-compose.yml 中预置了三种服务,按需取消注释即可启用:
- client(默认启用):运行客户端任务
- server(已注释):运行 HTTP 服务器,需配合
command: ["--server"]和config.server.toml - ssh-server(已注释):SSH 远端目标,供 sync 模式使用
也可直接构建镜像(注意 Dockerfile 路径):
docker build -f docker/app/Dockerfile -t smart-archiver .
docker run -d \
--name smart-archiver \
--restart unless-stopped \
-v $(pwd)/config:/app/config \
-v $(pwd)/logs:/app/logs \
-v /your/source:/app/source \
-v /your/dest:/app/dest \
smart-archiver为 SSH 同步模式提供即用的远端目标容器(Alpine + OpenSSH + rsync)。镜像发布到 ghcr.io/hexstan/sa-ssh-server。
# 在远端主机上运行
docker run -d \
--name sa-ssh-server \
--restart unless-stopped \
-p 2222:22 \
-e ROOT_PASSWORD=your_password \
-v /path/to/data:/data \
ghcr.io/hexstan/sa-ssh-server:latest
# 或使用密钥认证
docker run -d \
--name sa-ssh-server \
--restart unless-stopped \
-p 2222:22 \
-e AUTHORIZED_KEYS="ssh-rsa AAAA..." \
-v /path/to/data:/data \
ghcr.io/hexstan/sa-ssh-server:latest容器启动时自动生成 SSH 主机密钥,支持密码和公钥两种认证方式(通过 ROOT_PASSWORD 和 AUTHORIZED_KEYS 环境变量配置)。
SmartArchiver/
├── main.py # 程序入口:一次性 / cron / interval / --server 四种模式
├── config/
│ ├── config.example.toml # 客户端配置文件示例(带中文注释)
│ └── config.server.example.toml # 服务器模式配置文件示例
├── docker/
│ ├── app/Dockerfile # 应用镜像定义
│ └── ssh-server/ # SSH 远端服务器镜像(rsync + OpenSSH)
│ ├── Dockerfile
│ ├── entrypoint.sh
│ └── sshd_config
├── src/
│ ├── app_context.py # 单例上下文,解耦 Logger、HistoryManager、Config 传递
│ ├── logger.py # 日志系统:自定义级别、双格式输出、按天滚动
│ ├── history.py # 失败历史管理:记录失败次数,超阈值自动跳过
│ ├── presentation.py # 表现层:统��格式化调用,任务输出头/统计信息
│ ├── utils.py # 工具函数:文件锁、路径匹配、大小解析
│ ├── fs_ops.py # 本地文件系统操作原语(异常体系 + 结构化返回)
│ ├── remote/ # HTTP 远程通信
│ │ ├── __init__.py
│ │ ├── client.py # RemoteClient:HTTP 客户端,含重试与认证
│ │ ├── server.py # Flask 服务器:接收远程 API 调用
│ │ ├── concurrency.py # ConcurrencyGate:并发控制(信号量 + FIFO 队��)
│ │ ├── factory.py # 远程客户端工厂:解析 http_remotes 配置
│ │ └── protocol.py # RemoteAction 枚举 + API 路径常量
│ ├── ssh/ # SSH 远端支持
│ │ ├── __init__.py
│ │ └── config.py # SshRemote 数据类 + SSH 命令构建/执行
│ └── core/
│ ├── __init__.py # 包入口,触发 handler 注册,导出 process_task
│ ├── types.py # FileAction 枚举 + MoverStats 统计类
│ ├── filters.py # 规则引擎:keep/delete/whitelist 规则的解析与决策
│ ├── backend.py # DestBackend 抽象层:本地/HTTP/SSH 三种目标后端
│ ├── registry.py # 处理器注册中心:装饰器注册 + process_task 调度
│ └── handlers/
│ ├── base.py # 抽象基类 + FileChecker + ActionExecutor
│ ├── standard.py # StandardHandler:move / copy / whitelist_move / whitelist_copy
│ ├── rotate.py # RotateHandler:轮转模式 + RotateGroupManager
│ └── sync.py # SyncHandler:同步模式(rsync / rclone / SSH)
├── docker-compose.yml # Docker Compose 编排
└── requirements.txt # Python 依赖
SmartArchiver 支持 6 种任务模式,对应 3 个处理器。
处理器:StandardHandler
工作流程:
- 使用
os.walk自顶向下遍历源目录。 - 先处理目录:对每个子目录执行
FileFilterPolicy.decide(),判断是删除、保留还是继续遍历。如果目录被判定为 DELETE 且其mtime超过时间阈值,则递归删除整个目录。 - 再处理文件:对每个文件先通过
FileChecker三重检查(失败历史、mtime 阈值、文件锁),再通过规则引擎决策,最后执行传输或删除。 - 如果启用了
remove_empty_dirs,任务结束后自底向上清理空目录。
move 与 copy 的区别:仅在于传输时调用 shutil.move 还是 shutil.copy2。
whitelist 变体的区别:在执行 keep/delete 规则判断之前,先引入白名单过滤——只有命中白名单的文件/目录才会进入后续流程,未命中的直接跳过。
处理器:RotateHandler
工作流程:
- 扫描源目录下所有文件,构建「分组统计」(
RotateGroupManager)。 - 分组统计支持两个层级:
- 全局限制:
size_limit(总体积上限)和count_limit(总数量上限) - 规则级限制:
rotate_rules.size和rotate_rules.count,按命名模式匹配分组,每组有独立的大小或数量上限
- 全局限制:
- 将所有文件按
mtime升序排列(最旧的在前)。 - 从最旧的文件开始迭代,依次检查该文件所属的分组是否超出限制。如果任一关联分组已超限,则处理该文件(移动或删除)。
- 每处理完一个文件,更新该文件所属的所有分组的统计值。
- 循环直到所有分组均不超限为止。
- 如果文件已全部处理完毕但仍有分组超限,则记录警告日志。
重要细节:轮转模式下不使用 mtime_threshold_minutes,文件的「新旧」仅由 mtime 决定——最旧的最先被处理。如果同时配置了 dest,超限文件会被移动到目标目录;如果未配置 dest,文件将被留在原地(除非被 delete_rules 匹配删除)。
处理器:SyncHandler
工作流程:
- 调用
rsync -av --delete或rclone sync进行镜像同步(源目录和目标目录完全一致,目标中多余的文件会被删除)。 - 默认行为(
tool = "auto"):Windows 上自动使用 rclone,Linux/macOS 上自动使用 rsync。可通过tool配置项强制指定工具("rsync"或"rclone")。 - 支持通过
exclude列表排除不需要同步的文件/目录(通配符由底层工具处理)。 - 可选开启备份功能(
create_backups),在替换/删除文件前将其备份到.smart-archiver.backups/<时间戳>/目录,并通过max_backups限制保留的备份份数。
重要:同步模式不支持 SmartArchiver 的 keep/delete/whitelist 规则系统,所有过滤通过 rsync/rclone 的 --exclude 参数实现。
SSH 远端同步:sync 模式额外支持通过 SSH 将源目录同步到远端主机。配置 [[ssh_remotes]] 后,将 dest 设为 {ssh:别名}?路径 格式即可。底层通过 rsync -e "ssh ..." 或 rclone :sftp: 实现,支持密钥和密码两种认证方式。
SmartArchiver 支持三种目标后端:本地路径、HTTP 远端和 SSH 远端。后两者通过 {type:alias}?path 格式的 URL 语法指定 dest:
{http:my_nas}?/vol/backup→ 通过 HTTP API 连接另一台运行 SmartArchiver 的服务器{ssh:my_vps}?/var/data→ 通过 SSH 连接远端主机(仅 sync 模式支持)./local/path→ 普通本地路径(LocalDestBackend)
HTTP 远端和 SSH 远端的命名空间完全隔离,同一别名可同时在两者中使用。
适用于任何支持 dest 字段的任务模式(move、copy、whitelist、rotate)。客户端通过 HTTP API 将文件上传到远端服务器,由服务器执行实际的本地文件操作。
# 在 config.toml 中配置远端实例
[[http_remotes]]
alias = "my_nas"
address = "http://192.168.10.10:13579"
key = "your_api_key"
timeout = 14400 # 请求超时(秒),默认 4 小时
queue_time = 120 # 服务器繁忙时排队秒数,0 表示不排队远端需运行 python main.py --server 启动 HTTP 服务(详见服务器模式)。
仅 sync 模式支持。通过 SSH 连接远端主机,底层使用 rsync -e "ssh ..." 或 rclone :sftp: 进行镜像同步,无需远端运行 SmartArchiver 服务器。
# 在 config.toml 中配置 SSH 远端主机
[[ssh_remotes]]
alias = "my_vps"
host = "192.168.1.100"
user = "root"
port = 22 # 选填,默认 22
key_file = "/home/user/.ssh/id_rsa" # 选填,私钥路径
password_file = "/home/user/.ssh/pass" # 选填,明文密码文件路径远端需安装 rsync(rsync-over-SSH 模式)或启用 SFTP 子系统(rclone 模式)。可使用项目提供的 SSH 远端 Docker 镜像快速部署。
核心模块:FileFilterPolicy
规则系统决定了每个文件或目录的最终命运——传输、删除或跳过(保留)。
| 规则 | 作用 | 适用模式 |
|---|---|---|
keep_rules |
命中后保留文件/目录,不传输不删除 | move / copy / whitelist / rotate |
delete_rules |
命中后直接删除文件/目录 | move / copy / whitelist / rotate |
whitelist_rules |
白名单过滤,仅命中项进入后续流程 | whitelist_move / whitelist_copy |
rotate_rules |
轮转限制条件,触发文件处理 | rotate |
每组规则(keep/delete/whitelist)内部包含两类阈值匹配:
lt(less than):当目标小于阈值时命中ge(greater or equal):当目标大于等于阈值时命中
每个模式(pattern)的值是一个大小字符串(如 "10MB"、"1GB")或特殊值 -1(匹配所有大小)。模式支持通配符 * 和 ?。
规则系统通过模式末尾是否带 / 来区分匹配目标:
"*.log"(无尾部/)→ 匹配文件"logs/"(有尾部/)→ 匹配目录
例如 lt."*.log" = "10MB" 表示"匹配小于 10MB 的 .log 文件";ge."backups/" = "1GB" 表示"匹配大于等于 1GB 的 backups 目录"。
模式支持多级路径,通过 / 分隔:
"logs/*.log"— 匹配logs/目录下所有.log后缀的文件"data/*/cache/"— 匹配data/下一级子目录中的cache/目录"alpha/beta/charlie.txt"— 精确匹配路径alpha/beta/charlie.txt
目录大小的获取需要递归扫描,开销较大。规则引擎仅在必要时才触发大小计算:
- 先检查名称是否匹配模式(字符串比较,开销极低)。
- 如果存在
ge:"*" = -1这样的无条件命中规则,直接返回结果,不计算大小。 - 只有名称匹配且规则有实际大小阈值时,才调用
get_dir_size_and_mtime()逐个文件扫描。
这一设计使得大部分目录能在步骤 1 或 2 就完成判断,避免不必要的磁盘 I/O。
当一个文件同时命中 keep_rules 和 delete_rules 时,由 preferred_rule 决定最终行为:
preferred_rule = "keep"(默认):保留该文件(遵循 keep_rules)preferred_rule = "delete":删除该文件(遵循 delete_rules)
在白名单模式下,如果一个目录命中了 whitelist_rules,该目录会被记录到 whitelisted_dirs 集合中。后续遍历时,该目录下的所有子文件和子目录会自动继承白名单状态——即便它们自身的名称不匹配任何白名单规则。
这个设计解决了"我只配置了 whitelist_rules.ge."videos/" = -1,videos/ 下的 subdir/ 目录是否会被处理?"这类问题——答案是会,因为 subdir/ 的父目录 videos/ 已在白名单中。
仅在 move/copy/whitelist_move/whitelist_copy 模式下生效。轮转模式和同步模式不使用此参数。
在标准模式下,它同时作用于传输和删除——即便是 delete_rules 匹配的文件,也必须先满足 mtime 阈值才会被删除。这意味着你无法通过 delete_rules 删除最近才修改过的文件。
轮转模式的逻辑是:从最旧的文件开始处理,只要所有分组限制都满足就立即停止。它不是"把所有旧文件都删掉",而是"刚好处理到满足条件为止"。
例如:配置 count_limit = 10,目录有 15 个文件。轮转只会处理最旧的 5 个文件(移动或删除),保留最新的 10 个。
rotate_rules 的键(pattern)不支持以 / 结尾来匹配目录,仅支持匹配文件路径。如果需要约束某个目录下的文件数量或体积,应使用 "目录名/*" 的形式(如 "logs/*"),而非 "logs/"。
轮转模式先通过分组统计确定哪些文件需要处理(因超限而需要被移除),然后对每个待处理文件再执行 keep_rules / delete_rules 判断:
- 如果文件命中
keep_rules,则跳过不处理(该文件的统计值也会被保留) - 如果文件命中
delete_rules,则直接删除而不是移动到目标目录 - 如果都不命中,且配置了
dest,则移动到目标目录
一个典型用法:delete_rules.ge."*" = -1 — 让所有轮转产生的文件都直接删除而不是移动,实现纯清理效果。
在 os.walk 遍历过程中,规则引擎接收的是相对于源目录的路径。例如源目录是 ./source,文件是 ./source/a/b/file.txt,则传递给规则的是 a/b/file.txt。因此在配置规则时,应以源目录为根的相对路径来编写模式。
clean_empty_dirs() 在遍历时会跳过符号链接和挂载点(os.path.islink / os.path.ismount),避免意外卸载外部存储或删除特殊目录。
conflict_policy = "copy" 并非指任务模式为复制的意思。它的含义是:当目标位置已存在同名文件时,为源文件创建一个带编号的副本(如 file.txt → file-1.txt)。"copy" 在这里表示"保留两份",而非"覆盖"或"跳过"。
如果为每种场景都创建一个专属模式,SmartArchiver 的配置将变成一份冗长的菜单——move_logs_by_age、copy_videos_above_size、rotate_backups_by_count、sync_except_temp……每种组合都需要一个名字,用户只能在预设的选项中挑选,稍有偏差就无从下手。SmartArchiver 选择了一条不同的路径:提供少量正交的"动词"(move / copy / rotate / sync)和一套可组合的"条件表达式"(keep_rules / delete_rules / whitelist_rules / rotate_rules),让用户像搭积木一样,通过排列组合来表达任意文件管理逻辑。move 配上 mtime_threshold_minutes 就是"按时间归档";rotate 配上 delete_rules 就是"纯清理";whitelist_move 配上 keep_rules.lt 就是"只搬运某个目录下超过特定大小的文件"。模式负责"要做什么"(传输/删除/同步),规则负责"对谁做"(命名、大小、时间的筛选条件),两者解耦后,6 种模式 + 4 类规则所能表达的组合远远超过为每种组合单独设计一个模式。工程上,这也意味着规则引擎只需实现一次,所有处理器共享同一套决策逻辑,新增模式时也无需在配置层引入破坏性变更——因为规则语法对任何模式都是统一的。
在 keep_rules / delete_rules / whitelist_rules 中,规则系统通过模式末尾是否带 / 来区分匹配目标——"xxx/" 匹配目录自身,"xxx/*" 匹配目录下的文件。虽然两个模式的匹配对象不同(一个匹配目录、一个匹配文件),但 delete_rules 和 whitelist_rules 的行为简单直接、歧义小,用户按直觉配置通常不会出问题。
但是,给 rotate_rules 设计目录匹配逻辑极为困难。轮转模式的操作单元是文件:遍历文件列表、按 mtime 排序、逐个移除旧文件以腾出空间。如果引入目录级别的分组,会引发一系列难以自洽的问题:
-
"xxx/"的歧义问题在rotate_rules中会被严重放大,比如:rotate_rules.count."xxx/*" = 5→ 限制xxx/下的文件数量不超过 5 个(合理、直观)rotate_rules.count."xxx/" = 5→ 名为 xxx 的目录数量不超过 5 个?每个 xxx 目录下的文件数量不超过 5 个?所有名为 xxx 的目录下的总文件数量不超过 5 个?rotate_rules.size."xxx/" = "1GB"→ 每个 xxx 目录下的文件体积不超过 1GB?所有名为 xxx 的目录下的总体积不超过 1GB?
-
计数单位不统一:目录和文件如何统一计入同一个 count 限制?一个目录算一个文件还是算 N 个?
-
嵌套目录的归属:
"a/"匹配了目录a/,那么a/b/是否需要独立分组?还是两个目录共享同一个配额? -
mtime 排序失效:轮转从最旧的文件开始处理,但如果一个「旧目录」下有一个「新文件」,应该如何处理?取目录 mtime 还是文件 mtime?
-
移除语义模糊:轮转一个文件是指移动或删除它,那轮转一个目录是针对整个目录,还是目录中的部分旧文件?
这些问题没有唯一正确的答案,强行设计只会引入逻辑漏洞和配置歧义。因此 rotate_rules 刻意保持了仅匹配文件的简单语义,将复杂度留给更自然的 keep_rules / delete_rules 组合去解决(例如用 delete_rules 删除整个目录,或用 keep_rules 保护目录内的部分文件不被轮转处理)。
以下示例展示如何通过模式选择 + 规则组合实现一些项目没有显式设计但实际可完成的复杂需求:
mode = "move"
mtime_threshold_minutes = 10080 # 7 天
source = "./logs"
dest = "./log_archive"
keep_rules = {} # 不保留任何文件,全部移动
delete_rules = {}通过增大 mtime 阈值,只有超过 7 天未修改的文件才会被移动,最近 7 天的文件自动留在原地。
mode = "move"
mtime_threshold_minutes = 0
source = "./data"
dest = "./backup"
keep_rules.ge."*.important.cache" = -1
delete_rules.ge."*.tmp" = -1
delete_rules.ge."*.cache" = -1
preferred_rule = "delete"设置 preferred_rule = "delete" 确保 *.important.cache(同时命中 keep 和 delete)被保留;其余 .tmp 和 .cache 被删除。注意 mtime_threshold_minutes 设为 0 使所有文件立即参与处理。
mode = "rotate"
source = "./backups"
size_limit = "500MB"
keep_rules.ge."*recent*" = -1
delete_rules = {}轮转从最旧文件开始处理(移动或删除),但标记了 *recent* 的文件永远不会被处理(命中 keep_rules)。旋转只处理那些非 recent 且最旧的文件。
mode = "whitelist_move"
source = "./media"
dest = "./archive"
mtime_threshold_minutes = 0
whitelist_rules.ge."videos/*" = -1
whitelist_rules.ge."music/*" = -1
keep_rules.lt."*.mp4" = "100MB"
keep_rules.lt."*.flac" = "100MB"白名单限制了只有 videos/ 和 music/ 下的文件参与处理。再通过 keep_rules 将其中小于 100MB 的文件保留在原处。最终效果:只有 videos/ 和 music/ 下大于等于 100MB 的文件被移动。
mode = "rotate"
source = "./backups"
size_limit = "10GB"
count_limit = 5
rotate_rules.count."*.tar.gz" = 3
delete_rules.ge."*" = -1 # 命中轮转后直接删除,不移动
remove_empty_dirs = true全局最多保留 5 个文件且不超过 10GB,.tar.gz 备份最多保留 3 份。由于 delete_rules.ge."*" = -1,所有轮转处理都会直接删除,无需目标目录。
mode = "sync"
source = "./media_library"
dest = "/mnt/backup/media_library"
exclude = ["*.tmp", "*.partial", "Thumbs.db", ".cache/", "@eaDir/"]通过 rsync 的 exclude 排除临时文件和系统生成的文件,保持媒体库的干净镜像。