xianyufaka/generate_update_manifest.py
2026-04-15 22:56:44 +08:00

413 lines
12 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.

#!/usr/bin/env python3
"""
更新清单生成工具
此脚本用于生成更新清单包含所有可更新文件的MD5哈希值和大小信息。
生成的清单可用于 GitHub Releases + tag 文件热更新方案。
使用方法:
python generate_update_manifest.py
输出:
- 生成 update_files.json 文件
"""
import os
import sys
import json
import hashlib
import subprocess
from urllib import request
from pathlib import Path
from datetime import datetime
from typing import Any, Dict, List, Optional, Set
DEFAULT_GITHUB_OWNER = "GuDong2003"
DEFAULT_GITHUB_REPO = "xianyu-auto-reply-fix"
# static 目录下允许热更新的静态资源类型
STATIC_ASSET_EXTENSIONS = {
'.css', '.eot', '.gif', '.html', '.ico', '.jpeg', '.jpg',
'.js', '.json', '.map', '.otf', '.png', '.svg', '.ttf',
'.txt', '.webp', '.woff', '.woff2',
}
# 前端源码目录下允许热更新的源码类型
FRONTEND_SOURCE_DIRS = {'static', 'frontend'}
FRONTEND_SOURCE_EXTENSIONS = {'.ts', '.tsx', '.jsx', '.vue'}
# 不需要重启的文件扩展名
NO_RESTART_EXTENSIONS = (
STATIC_ASSET_EXTENSIONS
| FRONTEND_SOURCE_EXTENSIONS
| {'.yml', '.yaml'}
)
# 扫描时排除的目录
EXCLUDED_DIR_NAMES = {
'.pytest_cache',
'.git',
'.github',
'.claude',
'.ace-tool',
'__pycache__',
'browser_data',
'build',
'data',
'dist',
'logs',
'nginx',
'node_modules',
'qr_screenshots',
'trajectory_history',
'update_backup',
'uploads',
'venv',
}
# 即使文件类型匹配也不纳入热更新的文件
EXCLUDED_FILE_NAMES = {
'Dockerfile',
'Dockerfile-cn',
'docker-compose.yml',
'docker-compose-cn.yml',
'global_config.yml',
'update_files.json',
}
def calculate_md5(file_path: Path) -> str:
"""计算文件MD5"""
if not file_path.exists():
return ""
md5_hash = hashlib.md5()
with open(file_path, "rb") as f:
for chunk in iter(lambda: f.read(8192), b""):
md5_hash.update(chunk)
return md5_hash.hexdigest()
def get_file_size(file_path: Path) -> int:
"""获取文件大小"""
if not file_path.exists():
return 0
return file_path.stat().st_size
def needs_restart(file_path: str) -> bool:
"""判断文件更新是否需要重启"""
ext = Path(file_path).suffix.lower()
return ext not in NO_RESTART_EXTENSIONS
def normalize_manifest_path(path: str) -> str:
"""规范化清单中的路径字符串"""
return path.replace('\\', '/').lstrip('/')
def normalize_relative_path(path: Path) -> str:
"""规范化相对路径"""
return path.as_posix()
def is_excluded_path(relative_path: Path) -> bool:
"""检查路径是否属于排除项"""
if relative_path.name.startswith('.'):
return True
if relative_path.name in EXCLUDED_FILE_NAMES:
return True
return any(part in EXCLUDED_DIR_NAMES for part in relative_path.parts)
def is_updatable_file(relative_path: Path) -> bool:
"""判断文件是否应纳入热更新清单"""
if is_excluded_path(relative_path):
return False
suffix = relative_path.suffix.lower()
if suffix == '.py':
return True
if suffix == '.html':
return True
if not relative_path.parts:
return False
root_dir = relative_path.parts[0]
if root_dir == 'static' and suffix in STATIC_ASSET_EXTENSIONS:
return True
return root_dir in FRONTEND_SOURCE_DIRS and suffix in FRONTEND_SOURCE_EXTENSIONS
def collect_updatable_files(base_dir: Path) -> List[str]:
"""自动扫描可热更新的业务文件和静态资源"""
files: List[str] = []
for root, dirnames, filenames in os.walk(base_dir, topdown=True):
root_path = Path(root)
relative_root = root_path.relative_to(base_dir)
dirnames[:] = sorted(
dirname
for dirname in dirnames
if not is_excluded_path(relative_root / dirname)
)
for filename in sorted(filenames):
relative_path = relative_root / filename
if is_updatable_file(relative_path):
files.append(normalize_relative_path(relative_path))
return sorted(files)
def run_git_command(base_dir: Path, args: List[str]) -> str:
"""执行 git 命令并返回标准输出"""
result = subprocess.run(
['git', '-C', str(base_dir), *args],
check=True,
capture_output=True,
text=True,
)
return result.stdout.strip()
def get_previous_release_tag(base_dir: Path, current_version: str) -> Optional[str]:
"""获取当前版本之前的最近一个 tag"""
try:
output = run_git_command(base_dir, ['tag', '--list', 'v*', '--sort=-version:refname'])
except Exception:
return None
for raw_tag in output.splitlines():
tag = raw_tag.strip()
if tag and tag != current_version:
return tag
return None
def load_manifest_from_tag(base_dir: Path, tag: Optional[str]) -> Optional[Dict[str, Any]]:
"""从历史 tag 中加载 update_files.json"""
if not tag:
return None
owner, repo = read_repo_config()
manifest_url = f"https://github.com/{owner}/{repo}/releases/download/{tag}/update_files.json"
token = (
os.getenv('UPDATE_GITHUB_TOKEN')
or os.getenv('GITHUB_TOKEN')
or os.getenv('GH_TOKEN')
)
headers = {
'User-Agent': f'update-manifest-loader/{tag}',
'Accept': 'application/octet-stream',
}
if token:
headers['Authorization'] = f'Bearer {token}'
try:
req = request.Request(manifest_url, headers=headers)
with request.urlopen(req, timeout=30) as response:
manifest_text = response.read().decode('utf-8')
except Exception as exc:
print(f"提示: 无法从 release {tag} 读取 update_files.json: {exc}")
try:
manifest_text = run_git_command(base_dir, ['show', f'{tag}:update_files.json'])
except Exception as git_exc:
print(f"提示: 无法从 tag {tag} 读取 update_files.json: {git_exc}")
return None
try:
return json.loads(manifest_text)
except json.JSONDecodeError as exc:
print(f"提示: tag {tag} 的 update_files.json 解析失败: {exc}")
return None
def extract_manifest_paths(entries: List[Any]) -> Set[str]:
"""从 manifest 条目中提取路径集合"""
paths: Set[str] = set()
for entry in entries or []:
if isinstance(entry, dict):
path = entry.get('path', '')
else:
path = str(entry)
path = normalize_manifest_path(path).strip()
if path:
paths.add(path)
return paths
def build_deleted_files(current_paths: Set[str], previous_manifest: Optional[Dict[str, Any]]) -> List[Dict[str, Any]]:
"""基于历史 manifest 生成累积删除列表"""
if not previous_manifest:
return []
previous_paths = extract_manifest_paths(previous_manifest.get('files', []))
historical_deleted_paths = extract_manifest_paths(previous_manifest.get('deleted_files', []))
deleted_paths = sorted((historical_deleted_paths | (previous_paths - current_paths)) - current_paths)
return [
{
'path': path,
'requires_restart': needs_restart(path),
'description': '',
}
for path in deleted_paths
]
def build_raw_download_url(owner: str, repo: str, version: str, relative_path: str) -> str:
"""构建 GitHub raw 文件下载地址"""
relative_path = relative_path.replace('\\', '/').lstrip('/')
return f"https://raw.githubusercontent.com/{owner}/{repo}/{version}/{relative_path}"
def read_version(base_dir: Path, fallback: str = "v1.0.0") -> str:
"""读取版本号"""
version_file = base_dir / "static" / "version.txt"
if version_file.exists():
version = version_file.read_text(encoding='utf-8').strip()
if version:
return version
return fallback
def read_repo_config() -> tuple[str, str]:
"""读取 GitHub 仓库配置,优先环境变量,其次默认值"""
owner = os.environ.get("UPDATE_GITHUB_OWNER", "").strip()
repo = os.environ.get("UPDATE_GITHUB_REPO", "").strip()
if owner and repo:
return owner, repo
repository = os.environ.get("GITHUB_REPOSITORY", "").strip()
if repository and "/" in repository:
repo_owner, repo_name = repository.split("/", 1)
if repo_owner and repo_name:
return repo_owner, repo_name
return DEFAULT_GITHUB_OWNER, DEFAULT_GITHUB_REPO
def generate_manifest(
base_dir: Path,
version: str = "v1.0.0",
owner: str = DEFAULT_GITHUB_OWNER,
repo: str = DEFAULT_GITHUB_REPO,
previous_manifest: Optional[Dict[str, Any]] = None,
) -> dict:
"""生成更新清单"""
files = []
for file_path in collect_updatable_files(base_dir):
full_path = base_dir / file_path
md5 = calculate_md5(full_path)
size = get_file_size(full_path)
files.append({
'path': file_path.replace('\\', '/'),
'md5': md5,
'size': size,
'download_url': build_raw_download_url(owner, repo, version, file_path),
'requires_restart': needs_restart(file_path),
'description': '',
})
current_paths = {file_info['path'] for file_info in files}
deleted_files = build_deleted_files(current_paths, previous_manifest)
manifest = {
'version': version,
'release_date': datetime.now().strftime('%Y-%m-%d'),
'description': f'版本 {version} 更新',
'min_version': 'v1.0.0',
'changelog': [
'GitHub Releases 热更新清单',
],
'files': files,
'deleted_files': deleted_files,
}
return manifest
def print_manifest_summary(manifest: dict):
"""打印清单摘要"""
print("\n" + "=" * 60)
print("更新清单摘要")
print("=" * 60 + "\n")
print(f"版本号: {manifest['version']}")
print(f"发布日期: {manifest['release_date']}")
print(f"文件数量: {len(manifest['files'])}")
print(f"待删除文件数量: {len(manifest.get('deleted_files', []))}")
total_size = sum(f['size'] for f in manifest['files'])
print(f"总大小: {total_size / 1024:.2f} KB")
print("示例下载地址:")
if manifest['files']:
print(f" {manifest['files'][0]['download_url']}")
def main():
# 获取项目根目录
if len(sys.argv) > 1:
base_dir = Path(sys.argv[1])
else:
base_dir = Path(__file__).parent
# 获取版本号
version = read_version(base_dir)
if len(sys.argv) > 2:
version = sys.argv[2]
owner, repo = read_repo_config()
if len(sys.argv) > 3:
owner = sys.argv[3]
if len(sys.argv) > 4:
repo = sys.argv[4]
previous_tag = get_previous_release_tag(base_dir, version)
previous_manifest = load_manifest_from_tag(base_dir, previous_tag)
print(f"项目目录: {base_dir}")
print(f"版本号: {version}")
print(f"GitHub 仓库: {owner}/{repo}")
print(f"上一版本标签: {previous_tag or ''}")
# 生成清单
manifest = generate_manifest(base_dir, version, owner, repo, previous_manifest=previous_manifest)
# 保存JSON文件
output_file = base_dir / "update_files.json"
with open(output_file, 'w', encoding='utf-8') as f:
json.dump(manifest, f, ensure_ascii=False, indent=2)
print(f"\n已生成: {output_file}")
# 打印摘要
print_manifest_summary(manifest)
print("\n" + "=" * 60)
print("使用说明")
print("=" * 60)
print("""
1. 修改业务 Python 文件、HTML 页面或 static/ 下的静态资源
2. 更新 static/version.txt 为新的版本号后提交并 push 到 main
3. GitHub Actions 会自动扫描可热更新文件,生成 update_files.json 并创建同名 Release
4. 用户在前端点击"一键热更新"后,会先读取 GitHub Releases 最新版本,再从对应 tag 下载文件
""")
if __name__ == '__main__':
main()