Files
qinglong/脚本库/web版/账密/Bitwarden备份/2026-05-23_Sync_Password_e63fb7c9.py
2026-05-24 03:53:42 +00:00

237 lines
9.7 KiB
Python
Raw Permalink Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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.
# Source: https://github.com/CN-Grace/QinglongScripts/blob/main/Sync_Password.py
# Raw: https://raw.githubusercontent.com/CN-Grace/QinglongScripts/main/Sync_Password.py
# Repo: CN-Grace/QinglongScripts
# Path: Sync_Password.py
# UploadedAt: 2026-05-23T15:48:24Z
# SHA256: e63fb7c92c4a77f2dc4660a1ce3f0401a8173132dc4b8d36a99c7d383f91974e
# Category: web版/账密
# Evidence: web/H5关键词 + username/password/login
#!/usr/bin/env python3
"""
cron: 0 0 * * *
new Env("Bitwarden备份")
Bitwarden 自动备份脚本(带哈希比较,避免重复同步)
- 自动登录 Bitwarden 服务器并获取所有密码数据
- 将密码数据保存到本地 JSON 文件
- 比较今天与昨天的备份文件哈希,若无变化则跳过 WebDAV 同步
- 将备份文件同步到 WebDAV 服务器(仅当有更新时)
"""
import os
import json
import hashlib
import requests
import datetime
from requests.auth import HTTPBasicAuth
from typing import Dict, Any, Tuple
from utils import log_info, log_success, log_warning, log_error, beijing_now, beijing_time_str
from notifier import send as notify_send, send_file as notify_send_file
# ==================== 用户配置 ====================
BITWARDEN_SERVER = os.environ.get("BITWARDEN_SERVER", "")
BITWARDEN_USERNAME = os.environ.get("BITWARDEN_USERNAME", "")
BITWARDEN_PASSWORD = os.environ.get("BITWARDEN_PASSWORD", "")
WEBDAV_SERVER = os.environ.get("WEBDAV_SERVER", "")
WEBDAV_USERNAME = os.environ.get("WEBDAV_USERNAME", "")
WEBDAV_PASSWORD = os.environ.get("WEBDAV_PASSWORD", "")
WEBDAV_REMOTE_DIR = os.environ.get("WEBDAV_REMOTE_DIR", "")
LOCAL_BACKUP_PATH = os.environ.get("SYNC_LOCAL_DIR", "Password/")
BACKUP_FILENAME_FORMAT = "%Y-%m-%d_bitwarden_backup.json"
def create_session() -> requests.Session:
session = requests.Session()
session.headers.update({"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36", "Accept": "application/json, text/plain, */*"})
return session
def bitwarden_login(session: requests.Session) -> Tuple[bool, str]:
login_url = f"{BITWARDEN_SERVER.rstrip('/')}/identity/connect/token"
login_data = {
"scope": "api offline_access", "client_id": "web", "deviceType": "12",
"deviceIdentifier": "65ff3d73-fd5f-4835-8a50-fa7f41581f48", "deviceName": "edge",
"grant_type": "password", "username": BITWARDEN_USERNAME, "password": BITWARDEN_PASSWORD,
}
try:
log_info(f"正在登录 Bitwarden: {BITWARDEN_SERVER}")
resp = session.post(login_url, data=login_data, timeout=30)
resp.raise_for_status()
access_token = resp.json().get("access_token")
if access_token:
session.headers.update({"Authorization": f"Bearer {access_token}"})
log_success("Bitwarden 登录成功")
return True, "✅ Bitwarden 登录成功"
log_error("响应中未找到 access_token")
return False, "❌ Bitwarden 登录失败: 未找到 access_token"
except Exception as e:
log_error(f"Bitwarden 登录失败: {e}")
return False, f"❌ Bitwarden 登录失败: {e}"
def get_bitwarden_data(session: requests.Session) -> Tuple[bool, Any, str]:
sync_url = f"{BITWARDEN_SERVER.rstrip('/')}/api/sync"
try:
log_info("正在获取 Bitwarden 数据...")
resp = session.get(sync_url, params={"excludeDomains": "true"}, timeout=30)
resp.raise_for_status()
data = resp.json()
if data and isinstance(data, dict):
item_count = len(data.get("ciphers", []))
log_success(f"成功获取数据 ({item_count} 个密码项)")
return True, data, f"✅ 成功获取数据 ({item_count} 个密码项)"
return False, None, "⚠️ 数据为空或格式不正确"
except Exception as e:
log_error(f"获取数据失败: {e}")
return False, None, f"❌ 获取数据失败: {e}"
def save_backup_locally(data: Dict[str, Any]) -> Tuple[bool, str, str]:
if not os.path.exists(LOCAL_BACKUP_PATH):
os.makedirs(LOCAL_BACKUP_PATH, exist_ok=True)
today = beijing_now()
filename = today.strftime(BACKUP_FILENAME_FORMAT)
filepath = os.path.join(LOCAL_BACKUP_PATH, filename)
try:
with open(filepath, "w", encoding="utf-8") as f:
json.dump(data, f, indent=4, ensure_ascii=False)
file_size = os.path.getsize(filepath) / (1024 * 1024)
log_success(f"本地备份: {filename} ({file_size:.2f} MB)")
return True, filepath, f"✅ 本地备份成功: {filename} ({file_size:.2f} MB)"
except Exception as e:
log_error(f"本地备份失败: {e}")
return False, "", f"❌ 本地备份失败: {e}"
def get_file_hash(filepath: str) -> str:
hash_md5 = hashlib.md5()
with open(filepath, "rb") as f:
for chunk in iter(lambda: f.read(4096), b""):
hash_md5.update(chunk)
return hash_md5.hexdigest()
def sync_to_webdav(local_filepath: str) -> Tuple[bool, str]:
if not os.path.exists(local_filepath):
return False, "❌ WebDAV 同步失败: 本地文件不存在"
filename = os.path.basename(local_filepath)
remote_dir = WEBDAV_REMOTE_DIR.rstrip("/") if WEBDAV_REMOTE_DIR else "123Pan/Password"
remote_path = f"{WEBDAV_SERVER.rstrip('/')}/{remote_dir}/{filename}"
try:
with open(local_filepath, "rb") as f:
file_data = f.read()
log_info(f"正在上传到 WebDAV: {remote_path}")
resp = requests.put(url=remote_path, data=file_data, auth=HTTPBasicAuth(WEBDAV_USERNAME, WEBDAV_PASSWORD), headers={"Content-Type": "application/octet-stream"}, timeout=60)
if resp.status_code in (200, 201, 204):
log_success("WebDAV 同步成功")
return True, f"✅ WebDAV 同步成功: {filename}"
return False, f"❌ WebDAV 同步失败: 状态码 {resp.status_code}"
except Exception as e:
log_error(f"WebDAV 同步失败: {e}")
return False, f"❌ WebDAV 同步失败: {e}"
def build_report_content(log_messages: list) -> str:
lines = [
"🔐 Bitwarden 备份报告", "",
f"📅 备份时间: {beijing_time_str()}",
f"👤 账户: {BITWARDEN_USERNAME}", "",
"" * 18, "",
"📋 备份步骤状态:",
]
for i, msg in enumerate(log_messages, 1):
lines.append(f" {i}. {msg}")
success_count = sum(1 for m in log_messages if m.startswith(""))
info_count = sum(1 for m in log_messages if m.startswith("") or m.startswith("⚠️"))
skip_count = sum(1 for m in log_messages if m.startswith("⏭️"))
lines.extend(["", "" * 18, f"📊 统计: 成功 {success_count} | 信息 {info_count} | 跳过 {skip_count} | 总计 {len(log_messages)}", "", f"🕒 执行时间: {beijing_time_str()}"])
return "\n".join(lines)
def perform_backup() -> Tuple[bool, list, str, bool]:
log_messages = []
backup_filepath = None
has_update = True
session = create_session()
login_success, login_msg = bitwarden_login(session)
log_messages.append(login_msg)
if not login_success:
return False, log_messages, None, has_update
data_success, bitwarden_data, data_msg = get_bitwarden_data(session)
log_messages.append(data_msg)
if not data_success or bitwarden_data is None:
return False, log_messages, None, has_update
save_success, filepath, save_msg = save_backup_locally(bitwarden_data)
log_messages.append(save_msg)
backup_filepath = filepath if save_success else None
if save_success and backup_filepath:
today = beijing_now()
yesterday = today - datetime.timedelta(days=1)
yesterday_filename = yesterday.strftime(BACKUP_FILENAME_FORMAT)
yesterday_filepath = os.path.join(LOCAL_BACKUP_PATH, yesterday_filename)
if os.path.exists(yesterday_filepath):
if get_file_hash(backup_filepath) == get_file_hash(yesterday_filepath):
has_update = False
log_info("备份文件与昨天相同,无更新")
log_messages.append(" 备份文件与昨日一致,无新内容")
else:
log_info("备份文件有更新")
else:
log_info("昨日备份不存在,视为有更新")
if has_update:
sync_success, sync_msg = sync_to_webdav(backup_filepath)
log_messages.append(sync_msg)
else:
log_messages.append("⏭️ WebDAV 同步跳过: 文件无变化")
else:
log_messages.append("⏭️ WebDAV 同步跳过: 本地备份失败")
all_success = all(msg.startswith("") or msg.startswith("⏭️") or msg.startswith("") for msg in log_messages)
return all_success, log_messages, backup_filepath, has_update
def main():
log_info("=" * 50)
log_info("Bitwarden 自动备份脚本开始执行")
log_info("=" * 50)
start_time = beijing_now()
backup_success, log_messages, backup_filepath, has_update = perform_backup()
title = f"Bitwarden 备份报告"
content = build_report_content(log_messages)
if has_update and backup_filepath and os.path.exists(backup_filepath):
notify_send_file(title, content, backup_filepath)
else:
notify_send(title, content)
print(f"\n{'=' * 60}\nBitwarden 备份完成报告:\n{'=' * 60}")
for i, msg in enumerate(log_messages, 1):
print(f"{i}. {msg}")
print(f"\n执行时间: {(beijing_now() - start_time).total_seconds():.2f}")
print("=" * 60)
return 0 if backup_success else 1
if __name__ == "__main__":
import sys
try:
exit_code = main()
except KeyboardInterrupt:
log_info("用户中断执行")
exit_code = 130
except Exception as e:
log_error(f"脚本异常: {e}")
exit_code = 1
log_info(f"脚本执行结束,退出代码: {exit_code}")
sys.exit(exit_code)