wei/sync/sftp_sync_4.py

383 lines
14 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#weiyongfu
#sftp sync
#多线程可计划任务完成webhook通知
import paramiko
import os
import stat
from datetime import datetime
import sys
import threading
from concurrent.futures import ThreadPoolExecutor, as_completed
import requests
import json
import time
def send_webhook_notification(stats, status, error_message=None):
"""
发送Webhook通知 - 企业微信机器人版本
"""
# 您的企业微信机器人Webhook URL
webhook_url = "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=eb38eaac-c2a2-40a0-b64e-9a78edc67ebf"
# 构建企业微信机器人要求的消息格式
if status == "success":
content = f"✅ 文件同步成功\n\n" \
f" 同步统计:\n" \
f"• 总文件数: {stats['total_files']}\n" \
f"• 成功下载: {stats['downloaded']}\n" \
f"• 跳过文件: {stats['skipped']}\n" \
f"• 失败文件: {stats['failed']}\n" \
f"• 完成时间: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}"
elif status == "failed":
content = f"❌ 文件同步失败\n\n" \
f" 同步统计:\n" \
f"• 总文件数: {stats['total_files']}\n" \
f"• 成功下载: {stats['downloaded']}\n" \
f"• 跳过文件: {stats['skipped']}\n" \
f"• 失败文件: {stats['failed']}\n" \
f"• 错误信息: {error_message}\n" \
f"• 失败时间: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}"
elif status == "no_files":
content = f" 没有文件需要同步\n\n" \
f"• 检查时间: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}"
else:
content = f"文件同步状态: {status}\n" \
f"总文件数: {stats['total_files']}\n" \
f"完成时间: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}"
# 企业微信机器人要求的消息格式
notification_data = {
"msgtype": "text",
"text": {
"content": content
}
}
try:
response = requests.post(
webhook_url,
json=notification_data,
headers={'Content-Type': 'application/json'},
timeout=10
)
if response.status_code == 200:
result = response.json()
if result.get('errcode') == 0:
print("企业微信通知发送成功")
else:
print(f"企业微信通知发送失败,错误码: {result.get('errcode')}, 错误信息: {result.get('errmsg')}")
else:
print(f"企业微信通知发送失败HTTP状态码: {response.status_code}")
except requests.exceptions.RequestException as e:
print(f"企业微信通知发送错误: {e}")
def sync_remote_to_local():
# 服务器连接信息
hostname = "59.36.23.197"
port = 16888
username = "boyigo"
# 私钥文件路径
private_key_path = r"C:\Users\admin\Desktop\boyigo"
# 获取当天日期
today = datetime.now()
year = today.strftime("%Y")
month = today.strftime("%m")
day = today.strftime("%d")
date_ymd = today.strftime("%Y%m%d") # 格式: 20251114
date_md = today.strftime("%m%d") # 格式: 1114
# 根据日期动态生成同步路径
sync_paths = [
{
"remote": f"/list/10.99.9.75_sftp/L2/{date_ymd}SH",
"local": fr"E:\data\SHL2\{year}\{date_ymd}"
},
{
"remote": f"/list/10.99.9.75_sftp/L2/{date_ymd}SZ",
"local": fr"E:\data\SZL2\{year}\{date_md}"
}
]
# 统计信息
stats = {
'total_files': 0,
'downloaded': 0,
'skipped': 0,
'failed': 0
}
stats_lock = threading.Lock()
# 创建SSH客户端
ssh_client = paramiko.SSHClient()
ssh_client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
try:
print(f"{datetime.now().strftime('%Y-%m-%d %H:%M:%S')} - 开始连接服务器...")
print(f"主机: {hostname}:{port}, 用户: {username}")
# 尝试加载私钥文件
try:
# 首先尝试作为RSA密钥加载
private_key = paramiko.RSAKey.from_private_key_file(private_key_path)
print(f"使用RSA私钥文件: {private_key_path}")
except paramiko.ssh_exception.PasswordRequiredException:
# 如果私钥有密码保护,需要输入密码
key_password = input("请输入私钥密码: ")
private_key = paramiko.RSAKey.from_private_key_file(private_key_path, password=key_password)
except Exception as e1:
print(f"RSA密钥加载失败: {e1}")
try:
# 尝试作为ECDSA密钥加载
private_key = paramiko.ECDSAKey.from_private_key_file(private_key_path)
print(f"使用ECDSA私钥文件: {private_key_path}")
except Exception as e2:
print(f"ECDSA密钥加载失败: {e2}")
try:
# 尝试作为Ed25519密钥加载
private_key = paramiko.Ed25519Key.from_private_key_file(private_key_path)
print(f"使用Ed25519私钥文件: {private_key_path}")
except Exception as e3:
print(f"所有密钥格式尝试失败: {e3}")
print("请检查私钥文件路径和格式")
# 发送失败通知
send_webhook_notification(stats, "failed", f"密钥加载失败: {e3}")
return False
# 使用密钥建立连接
ssh_client.connect(hostname, port, username, pkey=private_key, timeout=30)
sftp_main = ssh_client.open_sftp()
print("连接成功,开始同步文件...")
print(f"同步日期: {date_ymd}")
# 收集所有需要同步的文件
all_files = []
for path_info in sync_paths:
remote_path = path_info["remote"]
local_base_path = path_info["local"]
print(f"\n扫描目录: {remote_path}")
files_in_path = scan_remote_directory(sftp_main, remote_path, local_base_path)
print(f" 找到 {len(files_in_path)} 个文件")
all_files.extend(files_in_path)
stats['total_files'] = len(all_files)
print(f"\n总共发现 {stats['total_files']} 个文件需要同步")
if stats['total_files'] == 0:
print("没有文件需要同步")
# 发送无文件通知
send_webhook_notification(stats, "no_files")
return True
# 使用多线程下载文件 - 5个线程
max_workers = 5
print(f"开始多线程下载 (线程数: {max_workers})...")
with ThreadPoolExecutor(max_workers=max_workers) as executor:
# 提交所有下载任务
future_to_file = {}
for file_info in all_files:
future = executor.submit(
download_file_with_new_connection,
file_info, stats, stats_lock,
hostname, port, username, private_key
)
future_to_file[future] = file_info
# 处理完成的任务
completed = 0
for future in as_completed(future_to_file):
result = future.result()
completed += 1
if completed % 10 == 0: # 每完成10个文件打印一次进度
print(f" 进度: {completed}/{stats['total_files']}")
# 打印统计信息
print(f"\n{datetime.now().strftime('%Y-%m-%d %H:%M:%S')} - 同步完成!")
print("=" * 50)
print(f"总文件数: {stats['total_files']}")
print(f"成功下载: {stats['downloaded']}")
print(f"跳过文件: {stats['skipped']}")
print(f"失败文件: {stats['failed']}")
print("=" * 50)
# 发送成功通知
send_webhook_notification(stats, "success")
except Exception as e:
error_msg = f"同步过程中出现错误: {e}"
print(error_msg)
import traceback
traceback.print_exc()
# 发送失败通知
send_webhook_notification(stats, "failed", error_msg)
return False
finally:
# 关闭连接
try:
sftp_main.close()
except:
pass
try:
ssh_client.close()
except:
pass
return True
def scan_remote_directory(sftp, remote_path, local_base_path):
"""扫描远程目录,返回所有需要同步的文件列表"""
files_list = []
try:
remote_items = sftp.listdir_attr(remote_path)
except Exception as e:
print(f" 无法访问远程目录 {remote_path}: {e}")
return files_list
for item in remote_items:
remote_item_path = os.path.join(remote_path, item.filename).replace('\\', '/')
if stat.S_ISDIR(item.st_mode):
# 如果是目录,递归扫描
sub_local_path = os.path.join(local_base_path, item.filename)
sub_files = scan_remote_directory(sftp, remote_item_path, sub_local_path)
files_list.extend(sub_files)
else:
# 如果是文件,添加到列表
local_file_path = os.path.join(local_base_path, item.filename)
files_list.append((remote_item_path, local_file_path, item))
return files_list
def download_file(sftp, file_info, stats, stats_lock):
"""下载单个文件"""
remote_file_path, local_file_path, remote_attr = file_info
try:
# 确保本地目录存在
local_dir = os.path.dirname(local_file_path)
os.makedirs(local_dir, exist_ok=True)
# 检查本地文件是否存在
if os.path.exists(local_file_path):
local_stat = os.stat(local_file_path)
local_size = local_stat.st_size
local_mtime = local_stat.st_mtime
# 获取远程文件修改时间
remote_mtime = remote_attr.st_mtime
# 如果文件大小和修改时间都相同,则跳过
if local_size == remote_attr.st_size and abs(local_mtime - remote_mtime) < 2:
with stats_lock:
stats['skipped'] += 1
return "skipped"
else:
print(f" 更新: {os.path.basename(remote_file_path)} (大小: {local_size} -> {remote_attr.st_size})")
else:
print(f" 下载: {os.path.basename(remote_file_path)} (大小: {remote_attr.st_size} bytes)")
# 下载文件
sftp.get(remote_file_path, local_file_path)
# 验证下载的文件大小
downloaded_size = os.path.getsize(local_file_path)
if downloaded_size != remote_attr.st_size:
# 如果大小不匹配,删除文件并重试一次
os.remove(local_file_path)
sftp.get(remote_file_path, local_file_path)
downloaded_size = os.path.getsize(local_file_path)
if downloaded_size != remote_attr.st_size:
raise Exception(f"文件大小不匹配: 期望 {remote_attr.st_size}, 实际 {downloaded_size}")
# 保持文件修改时间
os.utime(local_file_path, (remote_attr.st_atime, remote_attr.st_mtime))
with stats_lock:
stats['downloaded'] += 1
return "downloaded"
except Exception as e:
print(f" 下载失败 {os.path.basename(remote_file_path)}: {e}")
with stats_lock:
stats['failed'] += 1
return "failed"
def download_file_with_new_connection(file_info, stats, stats_lock,
hostname, port, username, private_key):
"""为多线程创建的下载函数每个线程有自己的SFTP连接"""
remote_file_path, local_file_path, remote_attr = file_info
# 为这个线程创建独立的SFTP连接
ssh_client = paramiko.SSHClient()
ssh_client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
sftp = None
try:
ssh_client.connect(hostname, port, username, pkey=private_key, timeout=30)
sftp = ssh_client.open_sftp()
# 调用原有的下载逻辑
return download_file(sftp, file_info, stats, stats_lock)
except Exception as e:
print(f" 连接失败 {os.path.basename(remote_file_path)}: {e}")
with stats_lock:
stats['failed'] += 1
return "failed"
finally:
# 关闭连接
try:
if sftp:
sftp.close()
except:
pass
try:
ssh_client.close()
except:
pass
def main():
"""主函数"""
try:
print("=" * 60)
print("远程文件同步工具 (5线程SFTP + 密钥认证 + Webhook通知)")
print("=" * 60)
success = sync_remote_to_local()
if success:
print("\n同步成功!")
return 0
else:
print("\n同步失败!")
return 1
except Exception as e:
print(f"程序发生未捕获异常: {e}")
import traceback
traceback.print_exc()
return 1
if __name__ == "__main__":
try:
exit_code = main()
except KeyboardInterrupt:
print("\n程序被用户中断")
exit_code = 1
except Exception as e:
print(f"程序发生严重错误: {e}")
import traceback
traceback.print_exc()
exit_code = 1
# 添加延时,确保所有输出都能显示
time.sleep(1)
print("\n程序执行完成,即将退出...")
input("按Enter键退出...")
sys.exit(exit_code)