diff --git a/ftp_client.py b/ftp_client.py index c7a3583..cc289c3 100644 --- a/ftp_client.py +++ b/ftp_client.py @@ -6,30 +6,180 @@ import ftplib import logging import shutil import datetime +import sys +import platform +import subprocess from urllib.parse import urlparse from logging.handlers import TimedRotatingFileHandler import socket -# ================= 配置与常量 ================= -CONFIG_FILE = 'conf.json' -MIN_FREE_SPACE_MB = 1024 # 设定磁盘最小剩余空间 (例如 1GB),低于此值触发清理 -SCAN_INTERVAL = 30 * 60 # 30 分钟(单位:秒) + +# ================= 全局配置与路径修正 ================= + +# 获取当前脚本所在的绝对目录 (关键:解决开机自启时工作目录不一致的问题) +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 # 扫描间隔 (秒) # 初始化调度器 scheduler = sched.scheduler(time.time, time.sleep) - -# 用于判断任务是否正在执行 is_task_running = False -def load_config(): - """读取配置文件""" - if not os.path.exists(CONFIG_FILE): - print(f"Error: {CONFIG_FILE} not found.") - return None - with open(CONFIG_FILE, 'r', encoding='utf-8') as f: - return json.load(f) +# ================= 模块一:自启动与卸载管理 ================= + +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) + +# ================= 模块二:基础工具 ================= + +def load_config(): + """读取并解析配置文件""" + if not os.path.exists(CONFIG_FILE): + 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}") + return None + +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 -def setup_logging(log_dir): - """配置日志系统,每天生成一个日志文件""" if not os.path.exists(log_dir): os.makedirs(log_dir) @@ -38,258 +188,245 @@ def setup_logging(log_dir): logger = logging.getLogger("FTP_Manager") logger.setLevel(logging.INFO) - # 避免重复添加handler if not logger.handlers: - # 按天回滚日志,保留最近30天 + # 按天回滚,保留30天 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) - # 同时输出到控制台(方便调试,后台运行时可忽略) - console_handler = logging.StreamHandler() - console_handler.setFormatter(formatter) + # 控制台输出 (方便手动调试时查看,后台运行时不影响) + console = logging.StreamHandler() + console.setFormatter(formatter) logger.addHandler(handler) - logger.addHandler(console_handler) + logger.addHandler(console) return logger -# ================= 核心功能逻辑 ================= +# ================= 模块三:核心业务逻辑 ================= -def check_and_clean_disk_space(download_path, logger): - """ - 检查磁盘空间。如果空间不足,删除最早一天的下载文件。 - 策略:根据文件的修改时间(mtime)将文件按日期分组,然后删除最老的一组。 - """ +def check_and_clean_disk_space(download_path_config, logger): + """磁盘清理策略""" try: + # 路径修正 + 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) + total, used, free = shutil.disk_usage(download_path) free_mb = free // (1024 * 1024) if free_mb < MIN_FREE_SPACE_MB: - logger.warning(f"磁盘空间不足 (剩余 {free_mb}MB),开始执行清理策略...") + logger.warning(f"磁盘空间不足 (剩余 {free_mb}MB < 阈值 {MIN_FREE_SPACE_MB}MB),启动清理...") - # 1. 遍历所有文件并按日期归类 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: - logger.info("下载目录为空,无需清理。") + logger.info("目录为空,无法清理。") return - # 2. 找到最早的日期 + # 删除最早日期的一批文件 sorted_dates = sorted(files_by_date.keys()) oldest_date = sorted_dates[0] + logger.info(f"正在删除 {oldest_date} 的旧文件...") - # 3. 删除该日期的所有文件 - logger.info(f"正在删除日期为 {oldest_date} 的旧文件以释放空间...") for fpath in files_by_date[oldest_date]: try: os.remove(fpath) logger.info(f"已删除: {fpath}") except Exception as e: - logger.error(f"删除文件失败 {fpath}: {e}") - + logger.error(f"删除失败 {fpath}: {e}") logger.info("清理完成。") - else: - logger.info(f"磁盘空间充足 (剩余 {free_mb}MB)。") except Exception as e: - logger.error(f"磁盘检查/清理过程中发生错误: {e}") + logger.error(f"磁盘维护错误: {e}") def run_ftp_job(config): - """ - 执行具体的FTP下载任务 (增强版:防断网、防中断) - """ + """执行 FTP 下载流程""" global is_task_running if is_task_running: - print("上一个任务仍在执行,跳过本次任务。") + print("警告: 上一轮任务未结束,跳过本次调度。") return is_task_running = True - logger = setup_logging(config['log_file_path']) - # logger.info("================ 开始执行定时任务 ================") - # 解析配置 - 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'] + # 确保日志路径正确 + logger = setup_logging(config.get('log_file_path', 'logs')) - # FTP 连接超时时间 (秒) - FTP_TIMEOUT = 60 - - if not os.path.exists(local_path): - os.makedirs(local_path) - - # 1. 检查磁盘空间 - check_and_clean_disk_space(local_path, logger) - - ftp = ftplib.FTP() try: - logger.info(f"正在连接 FTP 服务器: {ftp_host}:{ftp_port}") + # 解析配置 + 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 + + # 执行磁盘检查 + check_and_clean_disk_space(local_path, logger) + + ftp = ftplib.FTP() + logger.info(f"正在连接 FTP: {ftp_host}:{ftp_port}") - # 【优化点1】设置连接超时,防止网络卡死 ftp.connect(ftp_host, ftp_port, timeout=FTP_TIMEOUT) ftp.login(ftp_user, ftp_pass) ftp.set_pasv(True) - ftp.encoding = 'utf-8' # 防止中文乱码 + ftp.encoding = 'utf-8' - # 获取文件列表 try: files_list = ftp.nlst() except ftplib.error_perm: files_list = [] - logger.info(f"获取到文件列表: {len(files_list)} 个文件") - - # 筛选 .sure 文件 + logger.info(f"服务器文件数: {len(files_list)}") sure_files = [f for f in files_list if f.endswith('.sure')] for sure_file in sure_files: - target_file = sure_file[:-5] # 移除 .sure 后缀 + target_file = sure_file[:-5] # 去掉 .sure if target_file in files_list: local_file_path = os.path.join(local_path, target_file) - # 获取远程文件大小用于校验 + # 获取远程大小 try: remote_size = ftp.size(target_file) except: remote_size = -1 - # 检查本地是否已存在完整文件 + # 本地存在且大小一致则跳过 if os.path.exists(local_file_path): local_size = os.path.getsize(local_file_path) if remote_size != -1 and local_size == remote_size: - logger.info(f"文件已存在且完整,跳过: {target_file}") - # (可选) 这里也可以选择删除服务器上的文件,视业务需求而定 + logger.info(f"跳过已存在文件: {target_file}") + # 视需求决定是否删除服务器文件 + # ftp.delete(target_file); ftp.delete(sure_file) continue else: - logger.warning(f"本地文件 {target_file} 大小不一致 (本地:{local_size}, 远程:{remote_size}),准备重新下载") - os.remove(local_file_path) # 删除旧的不完整文件 + logger.warning(f"文件校验不一致,重新下载: {target_file}") + os.remove(local_file_path) logger.info(f"开始下载: {target_file}") - - # 【优化点2】使用 .tmp 临时文件路径 temp_file_path = local_file_path + ".tmp" - download_success = False + try: with open(temp_file_path, 'wb') as f: - # 使用回调写入,这里是网络中断最容易发生的地方 ftp.retrbinary(f'RETR {target_file}', f.write) - # 下载完成后的二次校验 if remote_size != -1: - downloaded_size = os.path.getsize(temp_file_path) - if downloaded_size != remote_size: - raise Exception(f"下载文件大小不匹配: 需 {remote_size}, 实得 {downloaded_size}") + if os.path.getsize(temp_file_path) != remote_size: + raise Exception("文件大小校验失败") - # 【优化点3】原子操作:下载成功且校验通过后,才重命名为正式文件 os.rename(temp_file_path, local_file_path) download_success = True - logger.info(f"下载并校验成功: {target_file}") + logger.info(f"下载完成: {target_file}") except Exception as dl_err: - logger.error(f"下载中断或失败 {target_file}: {dl_err}") - # 【优化点4】清理残留的垃圾文件 + logger.error(f"下载失败 {target_file}: {dl_err}") if os.path.exists(temp_file_path): - try: - os.remove(temp_file_path) - logger.info(f"已清理未完成的临时文件: {temp_file_path}") - except: - pass + os.remove(temp_file_path) - # 只有下载成功才删除服务器文件 if download_success: try: ftp.delete(target_file) ftp.delete(sure_file) - logger.info(f"已清理服务器文件: {target_file} & {sure_file}") + logger.info(f"已清理服务器文件: {target_file}") except Exception as del_err: - logger.error(f"服务器文件删除失败: {del_err}") - + logger.error(f"删除服务器文件失败: {del_err}") else: - logger.warning(f"发现 {sure_file} 但未找到源文件 {target_file}") - # 视情况决定是否删除孤立的 .sure 文件 - # ftp.delete(sure_file) - + logger.warning(f"孤立的标识文件: {sure_file}") + except (socket.timeout, socket.error) as net_err: - logger.error(f"网络连接错误 (可能是断网或超时): {net_err}") - except ftplib.all_errors as ftp_err: - logger.error(f"FTP 协议错误: {ftp_err}") + logger.error(f"网络错误: {net_err}") except Exception as e: - logger.error(f"未知错误: {e}") + logger.error(f"全局异常: {e}") finally: try: ftp.quit() except: - try: - ftp.close() # 强制关闭 - except: - pass - logger.info("本次任务结束") + pass + try: + ftp.close() + except: + pass is_task_running = False +# ================= 模块四:调度系统 ================= + def schedule_runner(): - """ - 计算下一次运行时间并加入调度器 - """ + """循环调度器""" config = load_config() if not config: + # 如果读不到配置,60秒后重试 + scheduler.enter(60, 1, schedule_runner, ()) return - logger = setup_logging(config['log_file_path']) + # 这里初始化日志是为了打印下次运行时间 + logger = setup_logging(config.get('log_file_path', 'logs')) - # 获取当前时间 now = datetime.datetime.now() + next_run = now + datetime.timedelta(seconds=SCAN_INTERVAL) - # 计算下次运行的延迟时间 - delay_seconds = SCAN_INTERVAL - logger.info(f"程序运行中... 下次扫描将在 {now + datetime.timedelta(seconds=delay_seconds)} 启动 (等待 {delay_seconds:.2f} 秒)") + logger.info(f"当前任务周期结束。下次扫描时间: {next_run.strftime('%Y-%m-%d %H:%M:%S')}") - # 加入调度器: - # 1. 执行任务 - # 2. 任务完成后,重新调用 schedule_runner 安排下一次(实现无限循环) - scheduler.enter(delay_seconds, 1, run_wrapper, (config,)) + # 安排下一次任务 + scheduler.enter(SCAN_INTERVAL, 1, run_wrapper, (config,)) def run_wrapper(config): - """ - 包装器:运行任务后,立即安排下一次调度 - """ + """包装器:先干活,再预约下一次""" run_ftp_job(config) schedule_runner() # ================= 主程序入口 ================= if __name__ == "__main__": - # 首次启动,先执行一次任务 + # --- 卸载模式 --- + 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. 读取配置 cfg = load_config() + if cfg: - log_dir = cfg.get('log_file_path', 'logger') - if not os.path.exists(log_dir): os.makedirs(log_dir) - print(f"Service started. Logs will be saved to {log_dir}") + print("配置加载成功,开始运行...") + print("提示: 使用 'python ftp_client.py --uninstall' 可移除开机自启。") - # 执行初次扫描任务 + # 立即执行一次 run_ftp_job(cfg) - # 启动调度器 + # 启动调度循环 schedule_runner() + try: - # 阻塞主线程,运行调度器 scheduler.run() except KeyboardInterrupt: - print("程序已手动停止") - \ No newline at end of file + print("\n程序已手动停止。") + else: + print("程序无法启动:缺少配置文件 conf.json") \ No newline at end of file diff --git a/read.txt b/read.txt index c7b8c7b..6e77220 100644 --- a/read.txt +++ b/read.txt @@ -10,6 +10,9 @@ systemd 是现代Linux发行版的初始化系统和服务管理器,它可以 sudo nano /etc/systemd/system/myscript.service +Windows: 打开 CMD 或 PowerShell,运行 python ftp_client.py。你会看到提示 [+] Windows 自启动已配置。 + +Linux: 运行 sudo python3 ftp_client.py。你会看到 [+] Linux Systemd 服务已创建并启用。 在文件中输入以下内容: