添加自启动

This commit is contained in:
fangjiajunzzz 2026-01-19 14:21:45 +08:00
parent eea69fca83
commit 45f06b7644
2 changed files with 275 additions and 135 deletions

View File

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

View File

@ -10,6 +10,9 @@ systemd 是现代Linux发行版的初始化系统和服务管理器它可以
sudo nano /etc/systemd/system/myscript.service sudo nano /etc/systemd/system/myscript.service
Windows: 打开 CMD 或 PowerShell运行 python ftp_client.py。你会看到提示 [+] Windows 自启动已配置。
Linux: 运行 sudo python3 ftp_client.py。你会看到 [+] Linux Systemd 服务已创建并启用。
在文件中输入以下内容: 在文件中输入以下内容: