ftp/ftp_client.py

432 lines
15 KiB
Python
Raw Permalink Normal View History

2026-01-19 13:26:42 +08:00
import json
import time
import sched
import os
import ftplib
import logging
import shutil
import datetime
2026-01-19 14:21:45 +08:00
import sys
import platform
import subprocess
2026-01-19 13:26:42 +08:00
from urllib.parse import urlparse
from logging.handlers import TimedRotatingFileHandler
import socket
2026-01-19 14:21:45 +08:00
# ================= 全局配置与路径修正 =================
# 获取当前脚本所在的绝对目录 (关键:解决开机自启时工作目录不一致的问题)
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
CONFIG_FILE = os.path.join(BASE_DIR, 'conf.json')
# 默认参数
MIN_FREE_SPACE_MB = 1024 # 磁盘最小剩余空间 (MB)
SCAN_INTERVAL = 30 * 60 # 扫描间隔 (秒)
2026-01-19 13:26:42 +08:00
# 初始化调度器
scheduler = sched.scheduler(time.time, time.sleep)
is_task_running = False
2026-01-19 14:21:45 +08:00
# ================= 模块一:自启动与卸载管理 =================
def setup_autostart():
"""
配置开机自启
Windows: 写入注册表 HKCU Run (使用 pythonw.exe 避免黑框)
Linux: 创建并启用 Systemd 服务 (支持崩溃重启)
"""
system_type = platform.system()
script_path = os.path.abspath(__file__)
# 简单的标志文件,避免每次运行都重复操作注册表/系统文件
lock_file = os.path.join(BASE_DIR, '.autostart_configured')
if os.path.exists(lock_file):
return
print(f"[*] 正在为 {system_type} 配置开机自启动...")
if system_type == "Windows":
try:
import winreg
key_path = r"Software\Microsoft\Windows\CurrentVersion\Run"
app_name = "FTP_Auto_Client"
# 替换为 pythonw.exe 以实现无窗口运行
python_exe = sys.executable.replace("python.exe", "pythonw.exe")
# 构造命令 (处理路径含空格的情况)
cmd = f'"{python_exe}" "{script_path}"'
key = winreg.OpenKey(winreg.HKEY_CURRENT_USER, key_path, 0, winreg.KEY_SET_VALUE)
winreg.SetValueEx(key, app_name, 0, winreg.REG_SZ, cmd)
winreg.CloseKey(key)
# 创建标记文件
with open(lock_file, 'w') as f: f.write("1")
print("[+] Windows 自启动已配置 (注册表 HKCU Run)。")
except Exception as e:
print(f"[-] Windows 配置失败: {e}")
elif system_type == "Linux":
if os.geteuid() != 0:
print("[!] 提示: 未使用 sudo 运行,跳过 Systemd 服务配置。")
print(f" 若需开机自启,请运行: sudo {sys.executable} {script_path}")
return
service_name = "ftp_client_service"
service_file = f"/etc/systemd/system/{service_name}.service"
python_exe = sys.executable
content = f"""[Unit]
Description=FTP Auto Download Service
After=network.target
[Service]
Type=simple
User=root
WorkingDirectory={BASE_DIR}
ExecStart={python_exe} {script_path}
Restart=always
RestartSec=60
StandardOutput=null
StandardError=null
[Install]
WantedBy=multi-user.target
"""
try:
with open(service_file, 'w') as f:
f.write(content)
subprocess.run(["systemctl", "daemon-reload"], check=True)
subprocess.run(["systemctl", "enable", service_name], check=True)
with open(lock_file, 'w') as f: f.write("1")
print(f"[+] Linux Systemd 服务已安装并启用: {service_name}")
except Exception as e:
print(f"[-] Linux 配置失败: {e}")
def remove_autostart():
"""
卸载开机自启配置
"""
system_type = platform.system()
lock_file = os.path.join(BASE_DIR, '.autostart_configured')
print(f"[*] 正在卸载 {system_type} 自启动配置...")
if system_type == "Windows":
try:
import winreg
key_path = r"Software\Microsoft\Windows\CurrentVersion\Run"
app_name = "FTP_Auto_Client"
key = winreg.OpenKey(winreg.HKEY_CURRENT_USER, key_path, 0, winreg.KEY_SET_VALUE)
try:
winreg.DeleteValue(key, app_name)
print("[+] Windows 注册表启动项已删除。")
except FileNotFoundError:
print("[-] 未找到启动项,可能已删除。")
winreg.CloseKey(key)
except Exception as e:
print(f"[-] 卸载出错: {e}")
elif system_type == "Linux":
if os.geteuid() != 0:
print("[!] 错误: 卸载服务需要 Root 权限 (sudo)。")
return
service_name = "ftp_client_service"
service_file = f"/etc/systemd/system/{service_name}.service"
try:
subprocess.run(["systemctl", "stop", service_name], stderr=subprocess.DEVNULL)
subprocess.run(["systemctl", "disable", service_name], stderr=subprocess.DEVNULL)
if os.path.exists(service_file):
os.remove(service_file)
print(f"[+] 服务文件已删除: {service_file}")
subprocess.run(["systemctl", "daemon-reload"], check=True)
print("[+] Linux Systemd 服务已彻底卸载。")
except Exception as e:
print(f"[-] 卸载出错: {e}")
# 清除标记文件
if os.path.exists(lock_file):
os.remove(lock_file)
# ================= 模块二:基础工具 =================
2026-01-19 13:26:42 +08:00
def load_config():
2026-01-19 14:21:45 +08:00
"""读取并解析配置文件"""
2026-01-19 13:26:42 +08:00
if not os.path.exists(CONFIG_FILE):
2026-01-19 14:21:45 +08:00
print(f"Error: 配置文件未找到 -> {CONFIG_FILE}")
return None
try:
with open(CONFIG_FILE, 'r', encoding='utf-8') as f:
return json.load(f)
except Exception as e:
print(f"Error: 配置文件读取失败: {e}")
2026-01-19 13:26:42 +08:00
return None
2026-01-19 14:21:45 +08:00
def setup_logging(log_dir_config):
"""初始化日志,确保路径绝对化"""
# 路径修正:如果是相对路径,转为绝对路径
if not os.path.isabs(log_dir_config):
log_dir = os.path.join(BASE_DIR, log_dir_config)
else:
log_dir = log_dir_config
2026-01-19 13:26:42 +08:00
if not os.path.exists(log_dir):
os.makedirs(log_dir)
log_file = os.path.join(log_dir, "ftp_dl.log")
logger = logging.getLogger("FTP_Manager")
logger.setLevel(logging.INFO)
if not logger.handlers:
2026-01-19 14:21:45 +08:00
# 按天回滚保留30天
2026-01-19 13:26:42 +08:00
handler = TimedRotatingFileHandler(log_file, when="midnight", interval=1, backupCount=30, encoding='utf-8')
formatter = logging.Formatter('%(asctime)s - [%(levelname)s] - %(message)s')
handler.setFormatter(formatter)
2026-01-19 14:21:45 +08:00
# 控制台输出 (方便手动调试时查看,后台运行时不影响)
console = logging.StreamHandler()
console.setFormatter(formatter)
2026-01-19 13:26:42 +08:00
logger.addHandler(handler)
2026-01-19 14:21:45 +08:00
logger.addHandler(console)
2026-01-19 13:26:42 +08:00
return logger
2026-01-19 14:21:45 +08:00
# ================= 模块三:核心业务逻辑 =================
2026-01-19 13:26:42 +08:00
2026-01-19 14:21:45 +08:00
def check_and_clean_disk_space(download_path_config, logger):
"""磁盘清理策略"""
2026-01-19 13:26:42 +08:00
try:
2026-01-19 14:21:45 +08:00
# 路径修正
if not os.path.isabs(download_path_config):
download_path = os.path.join(BASE_DIR, download_path_config)
else:
download_path = download_path_config
if not os.path.exists(download_path):
os.makedirs(download_path)
2026-01-19 13:26:42 +08:00
total, used, free = shutil.disk_usage(download_path)
free_mb = free // (1024 * 1024)
if free_mb < MIN_FREE_SPACE_MB:
2026-01-19 14:21:45 +08:00
logger.warning(f"磁盘空间不足 (剩余 {free_mb}MB < 阈值 {MIN_FREE_SPACE_MB}MB),启动清理...")
2026-01-19 13:26:42 +08:00
files_by_date = {}
for root, dirs, files in os.walk(download_path):
for name in files:
filepath = os.path.join(root, name)
mtime = os.path.getmtime(filepath)
date_str = datetime.datetime.fromtimestamp(mtime).strftime('%Y-%m-%d')
if date_str not in files_by_date:
files_by_date[date_str] = []
files_by_date[date_str].append(filepath)
if not files_by_date:
2026-01-19 14:21:45 +08:00
logger.info("目录为空,无法清理。")
2026-01-19 13:26:42 +08:00
return
2026-01-19 14:21:45 +08:00
# 删除最早日期的一批文件
2026-01-19 13:26:42 +08:00
sorted_dates = sorted(files_by_date.keys())
oldest_date = sorted_dates[0]
2026-01-19 14:21:45 +08:00
logger.info(f"正在删除 {oldest_date} 的旧文件...")
2026-01-19 13:26:42 +08:00
for fpath in files_by_date[oldest_date]:
try:
os.remove(fpath)
logger.info(f"已删除: {fpath}")
except Exception as e:
2026-01-19 14:21:45 +08:00
logger.error(f"删除失败 {fpath}: {e}")
2026-01-19 13:26:42 +08:00
logger.info("清理完成。")
except Exception as e:
2026-01-19 14:21:45 +08:00
logger.error(f"磁盘维护错误: {e}")
2026-01-19 13:26:42 +08:00
def run_ftp_job(config):
2026-01-19 14:21:45 +08:00
"""执行 FTP 下载流程"""
2026-01-19 13:26:42 +08:00
global is_task_running
if is_task_running:
2026-01-19 14:21:45 +08:00
print("警告: 上一轮任务未结束,跳过本次调度。")
2026-01-19 13:26:42 +08:00
return
is_task_running = True
2026-01-19 14:21:45 +08:00
# 确保日志路径正确
logger = setup_logging(config.get('log_file_path', 'logs'))
2026-01-19 13:26:42 +08:00
2026-01-19 14:21:45 +08:00
try:
# 解析配置
parse_result = urlparse(config['parse_ftp_url'])
ftp_host = parse_result.hostname
ftp_port = parse_result.port or 21
ftp_user = parse_result.username
ftp_pass = parse_result.password
# 路径处理
local_path = config['download_path']
if not os.path.isabs(local_path):
local_path = os.path.join(BASE_DIR, local_path)
FTP_TIMEOUT = 60
2026-01-19 13:26:42 +08:00
2026-01-19 14:21:45 +08:00
# 执行磁盘检查
check_and_clean_disk_space(local_path, logger)
2026-01-19 13:26:42 +08:00
2026-01-19 14:21:45 +08:00
ftp = ftplib.FTP()
logger.info(f"正在连接 FTP: {ftp_host}:{ftp_port}")
2026-01-19 13:26:42 +08:00
ftp.connect(ftp_host, ftp_port, timeout=FTP_TIMEOUT)
ftp.login(ftp_user, ftp_pass)
ftp.set_pasv(True)
2026-01-19 14:21:45 +08:00
ftp.encoding = 'utf-8'
2026-01-19 13:26:42 +08:00
try:
files_list = ftp.nlst()
except ftplib.error_perm:
files_list = []
2026-01-19 14:21:45 +08:00
logger.info(f"服务器文件数: {len(files_list)}")
2026-01-19 13:26:42 +08:00
sure_files = [f for f in files_list if f.endswith('.sure')]
for sure_file in sure_files:
2026-01-19 14:21:45 +08:00
target_file = sure_file[:-5] # 去掉 .sure
2026-01-19 13:26:42 +08:00
if target_file in files_list:
local_file_path = os.path.join(local_path, target_file)
2026-01-19 14:21:45 +08:00
# 获取远程大小
2026-01-19 13:26:42 +08:00
try:
remote_size = ftp.size(target_file)
except:
remote_size = -1
2026-01-19 14:21:45 +08:00
# 本地存在且大小一致则跳过
2026-01-19 13:26:42 +08:00
if os.path.exists(local_file_path):
local_size = os.path.getsize(local_file_path)
if remote_size != -1 and local_size == remote_size:
2026-01-19 14:21:45 +08:00
logger.info(f"跳过已存在文件: {target_file}")
# 视需求决定是否删除服务器文件
# ftp.delete(target_file); ftp.delete(sure_file)
2026-01-19 13:26:42 +08:00
continue
else:
2026-01-19 14:21:45 +08:00
logger.warning(f"文件校验不一致,重新下载: {target_file}")
os.remove(local_file_path)
2026-01-19 13:26:42 +08:00
logger.info(f"开始下载: {target_file}")
temp_file_path = local_file_path + ".tmp"
download_success = False
2026-01-19 14:21:45 +08:00
2026-01-19 13:26:42 +08:00
try:
with open(temp_file_path, 'wb') as f:
ftp.retrbinary(f'RETR {target_file}', f.write)
if remote_size != -1:
2026-01-19 14:21:45 +08:00
if os.path.getsize(temp_file_path) != remote_size:
raise Exception("文件大小校验失败")
2026-01-19 13:26:42 +08:00
os.rename(temp_file_path, local_file_path)
download_success = True
2026-01-19 14:21:45 +08:00
logger.info(f"下载完成: {target_file}")
2026-01-19 13:26:42 +08:00
except Exception as dl_err:
2026-01-19 14:21:45 +08:00
logger.error(f"下载失败 {target_file}: {dl_err}")
2026-01-19 13:26:42 +08:00
if os.path.exists(temp_file_path):
2026-01-19 14:21:45 +08:00
os.remove(temp_file_path)
2026-01-19 13:26:42 +08:00
if download_success:
try:
ftp.delete(target_file)
ftp.delete(sure_file)
2026-01-19 14:21:45 +08:00
logger.info(f"已清理服务器文件: {target_file}")
2026-01-19 13:26:42 +08:00
except Exception as del_err:
2026-01-19 14:21:45 +08:00
logger.error(f"删除服务器文件失败: {del_err}")
2026-01-19 13:26:42 +08:00
else:
2026-01-19 14:21:45 +08:00
logger.warning(f"孤立的标识文件: {sure_file}")
2026-01-19 13:26:42 +08:00
except (socket.timeout, socket.error) as net_err:
2026-01-19 14:21:45 +08:00
logger.error(f"网络错误: {net_err}")
2026-01-19 13:26:42 +08:00
except Exception as e:
2026-01-19 14:21:45 +08:00
logger.error(f"全局异常: {e}")
2026-01-19 13:26:42 +08:00
finally:
try:
ftp.quit()
except:
2026-01-19 14:21:45 +08:00
pass
try:
ftp.close()
except:
pass
2026-01-19 13:26:42 +08:00
is_task_running = False
2026-01-19 14:21:45 +08:00
# ================= 模块四:调度系统 =================
2026-01-19 13:26:42 +08:00
def schedule_runner():
2026-01-19 14:21:45 +08:00
"""循环调度器"""
2026-01-19 13:26:42 +08:00
config = load_config()
if not config:
2026-01-19 14:21:45 +08:00
# 如果读不到配置60秒后重试
scheduler.enter(60, 1, schedule_runner, ())
2026-01-19 13:26:42 +08:00
return
2026-01-19 14:21:45 +08:00
# 这里初始化日志是为了打印下次运行时间
logger = setup_logging(config.get('log_file_path', 'logs'))
2026-01-19 13:26:42 +08:00
now = datetime.datetime.now()
2026-01-19 14:21:45 +08:00
next_run = now + datetime.timedelta(seconds=SCAN_INTERVAL)
2026-01-19 13:26:42 +08:00
2026-01-19 14:21:45 +08:00
logger.info(f"当前任务周期结束。下次扫描时间: {next_run.strftime('%Y-%m-%d %H:%M:%S')}")
2026-01-19 13:26:42 +08:00
2026-01-19 14:21:45 +08:00
# 安排下一次任务
scheduler.enter(SCAN_INTERVAL, 1, run_wrapper, (config,))
2026-01-19 13:26:42 +08:00
def run_wrapper(config):
2026-01-19 14:21:45 +08:00
"""包装器:先干活,再预约下一次"""
2026-01-19 13:26:42 +08:00
run_ftp_job(config)
schedule_runner()
# ================= 主程序入口 =================
if __name__ == "__main__":
2026-01-19 14:21:45 +08:00
# --- 卸载模式 ---
if len(sys.argv) > 1 and sys.argv[1] == '--uninstall':
remove_autostart()
sys.exit(0)
# --- 正常模式 ---
print(f"[{datetime.datetime.now()}] 程序启动 (PID: {os.getpid()})")
print(f"工作目录: {BASE_DIR}")
# 1. 尝试自我配置 (开机自启)
setup_autostart()
# 2. 读取配置
2026-01-19 13:26:42 +08:00
cfg = load_config()
2026-01-19 14:21:45 +08:00
2026-01-19 13:26:42 +08:00
if cfg:
2026-01-19 14:21:45 +08:00
print("配置加载成功,开始运行...")
print("提示: 使用 'python ftp_client.py --uninstall' 可移除开机自启。")
2026-01-19 13:26:42 +08:00
2026-01-19 14:21:45 +08:00
# 立即执行一次
2026-01-19 13:26:42 +08:00
run_ftp_job(cfg)
2026-01-19 14:21:45 +08:00
# 启动调度循环
2026-01-19 13:26:42 +08:00
schedule_runner()
2026-01-19 14:21:45 +08:00
2026-01-19 13:26:42 +08:00
try:
scheduler.run()
except KeyboardInterrupt:
2026-01-19 14:21:45 +08:00
print("\n程序已手动停止。")
else:
print("程序无法启动:缺少配置文件 conf.json")