From e9e4411d6ab69a15d7ccdfbd365e04f45443e61a Mon Sep 17 00:00:00 2001 From: uye <99072975+ABA2396@users.noreply.github.com> Date: Thu, 9 Jul 2026 23:56:36 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=96=B0=E5=A2=9E=E4=B8=8B=E8=BD=BD?= =?UTF-8?q?=E9=87=8F=E7=BB=9F=E8=AE=A1=E5=B0=8F=E5=B7=A5=E5=85=B7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tools/ReleaseDownloadStats/.gitignore | 3 + .../release_download_stats.py | 1025 +++++++++++++++++ tools/ReleaseDownloadStats/start.bat | 23 + .../ReleaseDownloadStats/start_with_cache.bat | 23 + 4 files changed, 1074 insertions(+) create mode 100644 tools/ReleaseDownloadStats/.gitignore create mode 100644 tools/ReleaseDownloadStats/release_download_stats.py create mode 100644 tools/ReleaseDownloadStats/start.bat create mode 100644 tools/ReleaseDownloadStats/start_with_cache.bat diff --git a/tools/ReleaseDownloadStats/.gitignore b/tools/ReleaseDownloadStats/.gitignore new file mode 100644 index 0000000000..2c565b0e1f --- /dev/null +++ b/tools/ReleaseDownloadStats/.gitignore @@ -0,0 +1,3 @@ +# 生成产物 +report.html +data.json diff --git a/tools/ReleaseDownloadStats/release_download_stats.py b/tools/ReleaseDownloadStats/release_download_stats.py new file mode 100644 index 0000000000..4bdc4edd28 --- /dev/null +++ b/tools/ReleaseDownloadStats/release_download_stats.py @@ -0,0 +1,1025 @@ +#!/usr/bin/env python3 +"""统计 MaaAssistantArknights / MaaRelease 两个仓库的 Release 下载量,生成 HTML 报告。 + +用法: + python release_download_stats.py + python release_download_stats.py --output report.html + python release_download_stats.py --cache data.json --output report.html + +依赖: gh CLI(需已 gh auth login) +""" + +import argparse +import json +import subprocess +import sys +import shutil +import time +import re +from collections import defaultdict +from datetime import datetime +from pathlib import Path +from html import escape + + +def _find_gh() -> str: + """查找 gh CLI 可执行文件路径。""" + gh = shutil.which("gh") + if gh: + return gh + # Windows 常见安装路径 + for candidate in [ + r"C:\Program Files\GitHub CLI\gh.exe", + r"C:\Program Files (x86)\GitHub CLI\gh.exe", + str(Path.home() / "AppData" / "Local" / "Programs" / "GitHub CLI" / "gh.exe"), + ]: + if Path(candidate).exists(): + return candidate + return "gh" # 回退,让系统报错 + + +GH_EXECUTABLE = _find_gh() + +# ============================================================ +# 配置 +# ============================================================ + +REPOS = [ + "MaaAssistantArknights/MaaAssistantArknights", + "MaaAssistantArknights/MaaRelease", +] + +PER_PAGE = 100 +MAX_RETRIES = 3 + +# ============================================================ +# 数据获取层 +# ============================================================ + + +def gh_api_paginate(repo: str, path: str = "releases") -> list: + """通过 gh CLI 分页获取仓库的所有 Release 数据。""" + all_items = [] + page = 1 + while True: + url = f"repos/{repo}/{path}?per_page={PER_PAGE}&page={page}" + data = _gh_api_with_retry(url) + if not data: + break + all_items.extend(data) + print(f" [{repo}] 已获取 {len(all_items)} 条 (page {page})") + if len(data) < PER_PAGE: + break + page += 1 + return all_items + + +def _gh_api_with_retry(url: str) -> list: + """调用 gh api,失败自动重试 3 次,指数退避。""" + for attempt in range(MAX_RETRIES): + try: + result = subprocess.run( + [GH_EXECUTABLE, "api", url], + capture_output=True, + timeout=120, + encoding="utf-8", + errors="replace", + ) + if result.returncode == 0 and result.stdout and result.stdout.strip(): + return json.loads(result.stdout) + else: + stderr = (result.stderr or "").strip() + print(f" [警告] gh api 调用失败 (尝试 {attempt+1}/{MAX_RETRIES}): {stderr[:200]}") + except subprocess.TimeoutExpired: + print(f" [警告] gh api 超时 (尝试 {attempt+1}/{MAX_RETRIES})") + except Exception as e: + print(f" [警告] gh api 异常 (尝试 {attempt+1}/{MAX_RETRIES}): {e}") + + if attempt < MAX_RETRIES - 1: + wait = 2 ** attempt # 1s, 2s, 4s + print(f" 等待 {wait}s 后重试...") + time.sleep(wait) + + print(f" [错误] gh api {url} 重试 {MAX_RETRIES} 次后仍失败,跳过") + return [] + + +def fetch_all_releases(use_cache: str | None = None) -> dict: + """获取两个仓库的所有 Release 数据。支持缓存。""" + if use_cache: + cache_path = Path(use_cache) + if cache_path.exists(): + print(f"从缓存读取数据: {cache_path}") + with open(cache_path, "r", encoding="utf-8") as f: + return json.load(f) + + result = {} + for repo in REPOS: + print(f"\n正在获取 {repo} 的 Release 数据...") + releases = gh_api_paginate(repo) + result[repo] = releases + print(f" 共获取 {len(releases)} 个 Release") + + if use_cache: + cache_path = Path(use_cache) + cache_path.parent.mkdir(parents=True, exist_ok=True) + with open(cache_path, "w", encoding="utf-8") as f: + json.dump(result, f, ensure_ascii=False, indent=2) + print(f"数据已缓存到: {cache_path}") + + return result + + +# ============================================================ +# Asset 分类层 +# ============================================================ + +# 需要排除的 asset +EXCLUDE_PATTERNS = [ + re.compile(r"DebugSymbol", re.IGNORECASE), + re.compile(r"^appcast\.xml$", re.IGNORECASE), +] + +# OTA 包命名: MAAComponent-OTA-{from}_{to}-win-x64.zip +OTA_PATTERN = re.compile( + r"^MAAComponent-OTA-(.+?)_(.+?)-win.*$" +) + +# 完整包命名: MAA-{tag}-{platform}.{ext} +FULL_PKG_PATTERN = re.compile( + r"^MAA-(.+?)-(win-x64|win-arm64|macos-universal|macos-runtime-universal|linux-x86_64|linux-aarch64)\.(zip|dmg|tar\.gz|AppImage)$" +) + +# macOS delta 增量更新 +DELTA_PATTERN = re.compile(r"^MAA\d+-\d+\.delta$") + + +def classify_asset(name: str) -> tuple[str, dict]: + """分类 asset,返回 (类型, 额外信息)。被排除的返回 ('excluded', {})。""" + for pat in EXCLUDE_PATTERNS: + if pat.search(name): + return ("excluded", {}) + + m = OTA_PATTERN.match(name) + if m: + return ("ota", {"from_version": m.group(1), "to_version": m.group(2)}) + + if DELTA_PATTERN.match(name): + return ("delta", {}) + + m = FULL_PKG_PATTERN.match(name) + if m: + platform = m.group(2) + platform_map = { + "win-x64": "Windows-x64", + "win-arm64": "Windows-arm64", + "macos-universal": "macOS", + "macos-runtime-universal": "macOS", + "linux-x86_64": "Linux-x86_64", + "linux-aarch64": "Linux-aarch64", + } + return ("full", {"platform": platform_map.get(platform, platform)}) + + return ("other", {}) + + +# ============================================================ +# 数据处理层 +# ============================================================ + + +def process_releases(raw_data: dict) -> dict: + """处理原始 Release 数据,生成结构化的统计数据。""" + # 合并后的 Release 列表: {tag -> release_info} + merged_releases = {} + + for repo, releases in raw_data.items(): + for rel in releases: + tag = rel.get("tag_name", "") + if not tag: + continue + + if tag not in merged_releases: + merged_releases[tag] = { + "tag": tag, + "name": rel.get("name") or tag, + "published_at": rel.get("published_at") or rel.get("created_at", ""), + "repo_sources": [], + "assets": {}, + "total_downloads": 0, + "ota_sources": defaultdict(int), + "full_downloads": defaultdict(int), + "ota_downloads": 0, + "delta_downloads": 0, + "other_downloads": 0, + } + + entry = merged_releases[tag] + entry["repo_sources"].append(repo) + + for asset in rel.get("assets", []): + aname = asset.get("name", "") + count = asset.get("download_count", 0) + + atype, info = classify_asset(aname) + if atype == "excluded": + continue + + # 同名 asset 合并下载量(主仓 + Release 仓) + if aname in entry["assets"]: + entry["assets"][aname]["count"] += count + else: + entry["assets"][aname] = { + "name": aname, + "count": count, + "type": atype, + "info": info, + } + + # 计算每个 Release 的汇总 + for tag, entry in merged_releases.items(): + total = 0 + for aname, a in entry["assets"].items(): + total += a["count"] + if a["type"] == "ota": + entry["ota_downloads"] += a["count"] + from_v = a["info"].get("from_version", "unknown") + entry["ota_sources"][from_v] += a["count"] + elif a["type"] == "full": + entry["full_downloads"][a["info"].get("platform", "unknown")] += a["count"] + elif a["type"] == "delta": + entry["delta_downloads"] += a["count"] + else: + entry["other_downloads"] += a["count"] + entry["total_downloads"] = total + # defaultdict 转 dict 以便序列化 + entry["ota_sources"] = dict(entry["ota_sources"]) + entry["full_downloads"] = dict(entry["full_downloads"]) + + # 按发布日期排序(旧 → 新) + ordered_releases = sorted( + merged_releases.values(), + key=lambda r: r["published_at"] or "", + ) + + # 全局汇总 + total_all = sum(r["total_downloads"] for r in ordered_releases) + total_full_sum = sum(sum(r["full_downloads"].values()) for r in ordered_releases) + total_ota_sum = sum(r["ota_downloads"] for r in ordered_releases) + total_delta_sum = sum(r["delta_downloads"] for r in ordered_releases) + total_other_sum = sum(r["other_downloads"] for r in ordered_releases) + + # Top-20 Release(按总下载量排序) + top_releases = sorted( + [(r["tag"], r["total_downloads"]) for r in ordered_releases], + key=lambda x: -x[1], + )[:20] + + summary = { + "total_releases": len(ordered_releases), + "total_downloads": total_all, + "full_downloads": total_full_sum, + "ota_downloads": total_ota_sum, + "delta_downloads": total_delta_sum, + "other_downloads": total_other_sum, + "per_platform": _aggregate_platforms(ordered_releases), + "top_releases": top_releases, + } + + return { + "releases": ordered_releases, + "summary": summary, + } + + +def _aggregate_platforms(releases: list) -> dict: + """聚合所有 Release 中按平台的完整包下载量。""" + platform_totals = defaultdict(int) + for r in releases: + for platform, count in r["full_downloads"].items(): + platform_totals[platform] += count + return dict(sorted(platform_totals.items(), key=lambda x: -x[1])) + + +# ============================================================ +# HTML 报告生成层 +# ============================================================ + + +def generate_html(processed: dict, output_path: Path): + """生成 HTML 报告。""" + releases = processed["releases"] + summary = processed["summary"] + gen_time = datetime.now().strftime("%Y-%m-%d %H:%M:%S") + + # --- 准备图表数据 --- + # 包类型饼图 + pie_labels = ["完整包", "OTA 更新包", "macOS Delta", "其他"] + pie_data = [ + summary["full_downloads"], + summary["ota_downloads"], + summary["delta_downloads"], + summary["other_downloads"], + ] + + # 平台分布柱状图 + platform_labels = list(summary["per_platform"].keys()) + platform_data = list(summary["per_platform"].values()) + + # 累计下载趋势曲线(按发布日期正序:旧 → 新) + # 只保留正式版和 beta 版,过滤 alpha / dev 内测版 + # 兼容多种历史版本号格式: + # v6.14.0, v6.14.0-beta.2, v2.9.0-beta, v1.3-beta.2 (v 前缀) + # release.stable.1.0, release.stable.1.1-beta.2 (早期 stable) + # release.beta.08.06, release.beta.01.2 (早期 beta) + def is_release_or_beta(tag: str) -> bool: + tag = tag.strip() + # 排除 alpha / dev 内测版 + if "alpha" in tag.lower() or ".d0" in tag: + return False + # v 前缀格式: vX.Y.Z 或 vX.Y.Z-beta.N 或 vX.Y-beta 或 vX.Y-beta.N + if re.match(r"^v\d+\.\d+[\.\d]*(-beta(\.\d+)?)?$", tag): + return True + # 早期 stable 格式 + if tag.startswith("release.stable."): + return True + # 早期 beta 格式 + if tag.startswith("release.beta."): + return True + return False + + trend_releases = [r for r in releases if is_release_or_beta(r["tag"])] + trend_labels = [r["tag"] for r in trend_releases] + trend_cumulative = [] + cum = 0 + for r in trend_releases: + cum += r["total_downloads"] + trend_cumulative.append(cum) + + # --- 构建 Release 明细表 --- + rows_html = [] + for i, r in enumerate(reversed(releases)): # 表格最新在最上面 + # OTA Top-5 来源 + ota_sources_sorted = sorted(r["ota_sources"].items(), key=lambda x: -x[1]) + ota_top5 = ota_sources_sorted[:5] + ota_total = r["ota_downloads"] + + ota_bars = "" + if ota_total > 0 and ota_top5: + ota_bars = "
" + for src, count in ota_top5: + pct = (count / ota_total * 100) if ota_total > 0 else 0 + ota_bars += ( + f"
" + f"{escape(src)}" + f"
" + f"
" + f"
" + f"{count:,} ({pct:.1f}%)" + f"
" + ) + ota_bars += "
" + + # asset 明细(可展开) + assets_sorted = sorted(r["assets"].values(), key=lambda x: -x["count"]) + assets_html = "" + if assets_sorted: + assets_html = "" + type_names = {"full": "完整包", "ota": "OTA", "delta": "Delta", "other": "其他"} + for a in assets_sorted: + assets_html += ( + f"" + f"" + f"" + ) + assets_html += "
Asset类型下载量
{escape(a['name'])}{type_names.get(a['type'], a['type'])}{a['count']:,}
" + + # 平台下载分布 + platform_cells = "" + if r["full_downloads"]: + platform_cells = " / ".join( + f"{escape(p)}: {c:,}" for p, c in sorted(r["full_downloads"].items(), key=lambda x: -x[1]) + ) + + pub_date = r["published_at"][:10] if r["published_at"] else "N/A" + + full_sum = sum(r['full_downloads'].values()) + # 判断版本渠道(带 .d0 的 dev 推进版一律为内测版) + tag_lower = r['tag'].lower() + is_dev = '.d0' in r['tag'] + if is_dev or 'alpha' in tag_lower: + channel = 'alpha' + elif 'beta' in tag_lower: + channel = 'beta' + else: + channel = 'stable' + rows_html.append(f""" + + {escape(r['tag'])} + {pub_date} + {full_sum:,} + {r['ota_downloads']:,} + {r['total_downloads']:,} + {platform_cells} + + + + {ota_bars} + {assets_html} + + + """) + + # Top-20 Release 柱状图数据(从高到低) + top_rel_tags = [escape(tag) for tag, _ in summary["top_releases"]] + top_rel_counts = [count for _, count in summary["top_releases"]] + + # 将图表数据嵌入 JSON + chart_data = json.dumps({ + "pie_labels": pie_labels, + "pie_data": pie_data, + "platform_labels": platform_labels, + "platform_data": platform_data, + "trend_labels": trend_labels, + "trend_cumulative": trend_cumulative, + "top_rel_tags": top_rel_tags, + "top_rel_counts": top_rel_counts, + }, ensure_ascii=False) + + html = f""" + + + + + MAA GitHub Release 下载量统计 + + + + +

MAA GitHub Release 下载量统计

+

+ 仅统计 GitHub Release 下载量,不包含其他分发渠道(如镜像站、包管理器等) |  + 数据来源: MaaAssistantArknights/MaaAssistantArknights + MaaAssistantArknights/MaaRelease  |  + 生成时间: {gen_time} +

+ + +
+
+
{summary['total_downloads']:,}
+
总下载量
+
+
+
{summary['full_downloads']:,}
+
完整包下载量
+
+
+
{summary['ota_downloads']:,}
+
OTA 更新包下载量
+
+
+
{summary['total_releases']:,}
+
Release 总数
+
+
+ + +
+
+

📦 包类型占比

+ +
+
+

🖥️ 完整包平台分布

+ +
+
+

📈 累计下载量趋势

+ +
+
+ + +

📋 Release 明细

+

点击行展开查看 OTA 升级来源 Top-5 和 Asset 详情;点击表头排序

+
+ + + +
+
+ + + + + + + + + + + + + {''.join(rows_html)} + +
版本发布日期 完整包 OTA 合计 平台分布
+
+ + +

🏆 下载量 Top-20 Release

+
+ +
+ +

+ 本报告由 release_download_stats.py 自动生成 +

+ + + +""" + + output_path.parent.mkdir(parents=True, exist_ok=True) + with open(output_path, "w", encoding="utf-8") as f: + f.write(html) + print(f"\n报告已生成: {output_path}") + + +# ============================================================ +# 主入口 +# ============================================================ + + +def main(): + parser = argparse.ArgumentParser(description="MAA Release 下载量统计 → HTML 报告") + parser.add_argument( + "--cache", type=str, default=None, + help="缓存文件路径。存在则直接读取,不存在则拉取数据后写入缓存。", + ) + parser.add_argument( + "--output", "-o", type=str, default=None, + help="输出 HTML 文件路径(默认: report.html)", + ) + args = parser.parse_args() + + cur_dir = Path(__file__).parent + cache_path = args.cache if args.cache else None + output_path = Path(args.output) if args.output else cur_dir / "report.html" + + # 获取数据 + raw_data = fetch_all_releases(use_cache=cache_path) + + # 处理数据 + print("\n正在处理数据...") + processed = process_releases(raw_data) + + # 生成报告 + print("\n正在生成 HTML 报告...") + generate_html(processed, output_path) + + # 打印摘要 + s = processed["summary"] + print(f"\n{'='*50}") + print(f" Release 总数: {s['total_releases']}") + print(f" 总下载量: {s['total_downloads']:,}") + print(f" 完整包: {s['full_downloads']:,}") + print(f" OTA 更新包: {s['ota_downloads']:,}") + print(f" macOS Delta: {s['delta_downloads']:,}") + print(f" 其他: {s['other_downloads']:,}") + print(f"{'='*50}") + + +if __name__ == "__main__": + main() diff --git a/tools/ReleaseDownloadStats/start.bat b/tools/ReleaseDownloadStats/start.bat new file mode 100644 index 0000000000..e7f2563b08 --- /dev/null +++ b/tools/ReleaseDownloadStats/start.bat @@ -0,0 +1,23 @@ +@echo off +chcp 65001 >nul 2>&1 +cd /d "%~dp0" + +echo ======================================== +echo MAA Release 下载量统计工具 +echo ======================================== +echo. + +python release_download_stats.py %* + +if %ERRORLEVEL% NEQ 0 ( + echo. + echo [错误] 脚本执行失败 + pause +) else ( + echo. + echo 完成! + echo 正在打开报告... + start "" "report.html" +) + +pause diff --git a/tools/ReleaseDownloadStats/start_with_cache.bat b/tools/ReleaseDownloadStats/start_with_cache.bat new file mode 100644 index 0000000000..8eaea11dbb --- /dev/null +++ b/tools/ReleaseDownloadStats/start_with_cache.bat @@ -0,0 +1,23 @@ +@echo off +chcp 65001 >nul 2>&1 +cd /d "%~dp0" + +echo ======================================== +echo MAA Release 下载量统计工具 (带缓存) +echo ======================================== +echo. + +python release_download_stats.py --cache data.json --output report.html %* + +if %ERRORLEVEL% NEQ 0 ( + echo. + echo [错误] 脚本执行失败 + pause +) else ( + echo. + echo 完成! + echo 正在打开报告... + start "" "report.html" +) + +pause