diff --git a/.github/workflows/repository-metrics.yml b/.github/workflows/repository-metrics.yml new file mode 100644 index 0000000..9110ba0 --- /dev/null +++ b/.github/workflows/repository-metrics.yml @@ -0,0 +1,39 @@ +name: repository-metrics + +on: + watch: + types: [started] + schedule: + - cron: "17 5 * * 1" + workflow_dispatch: + +permissions: + contents: write + +concurrency: + group: repository-metrics + cancel-in-progress: true + +jobs: + render: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + - name: Render privacy-safe metrics SVG + env: + GITHUB_TOKEN: ${{ github.token }} + run: >- + python3 scripts/render_repository_metrics.py + --repository "$GITHUB_REPOSITORY" + --output "$RUNNER_TEMP/repository-metrics.svg" + - name: Publish generated image to the metrics branch + run: | + set -euo pipefail + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git checkout --orphan repository-metrics-output + git rm -rf . + cp "$RUNNER_TEMP/repository-metrics.svg" repository-metrics.svg + git add repository-metrics.svg + git commit -m "chore: refresh repository metrics" + git push --force origin HEAD:metrics diff --git a/README.md b/README.md index dfd3efe..b4caeb4 100644 --- a/README.md +++ b/README.md @@ -60,15 +60,15 @@ credential store, and deterministic per-agent projections. Traffic snapshot: 2026-08-10. GitHub exposes clone and unique-visitor analytics only to maintainers, so those values are a dated, transparent snapshot rather than a token-backed public badge. -### Star history +### Repository growth
-Live white-background chart from Star History. It follows public GitHub star data and becomes more informative as the repository grows. +Auto-refreshed after every new star and once a week. GitHub Traffic uses the rolling 14-day owner view. No long-lived token is embedded in this README. The native app follows the macOS language preference and ships complete **English** and **Simplified Chinese** interface catalogs. diff --git a/README.zh-CN.md b/README.zh-CN.md index db47b31..0af7816 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -58,15 +58,15 @@ Agent Switch 用一个本地 MCP 注册表、一个私密凭据存储和可重 数据快照日期:2026-08-10。GitHub 只向仓库维护者提供克隆与独立访客数据,因此这里采用注明日期的透明快照,而不是需要私密 Token 的公开徽章。 -### Star 增长曲线 +### 仓库增长曲线 -这是由 Star History 提供的动态白底增长曲线,读取 GitHub 公开 Star 数据;随着项目获得更多 Star,曲线会自动变得更完整。 +每次获得新 Star 后自动刷新,并每周定时更新。GitHub Traffic 使用仓库所有者可见的 14 天滚动窗口;README 中不嵌入任何长期 Token。 原生应用会跟随 macOS 的语言偏好,并完整提供**英文**与**简体中文**界面。 diff --git a/scripts/render_repository_metrics.py b/scripts/render_repository_metrics.py new file mode 100755 index 0000000..758d2c7 --- /dev/null +++ b/scripts/render_repository_metrics.py @@ -0,0 +1,239 @@ +#!/usr/bin/env python3 +"""Render a privacy-safe repository metrics SVG from GitHub-owned data.""" + +from __future__ import annotations + +import argparse +from datetime import datetime, timezone +from html import escape +import json +import math +import os +from pathlib import Path +import re +from typing import Any +from urllib.parse import quote +from urllib.request import Request, urlopen + + +API = "https://api.github.com" +API_VERSION = "2022-11-28" + + +def _request_json(path: str, token: str, *, accept: str = "application/vnd.github+json") -> tuple[Any, Any]: + request = Request( + f"{API}{path}", + headers={ + "Accept": accept, + "Authorization": f"Bearer {token}", + "User-Agent": "repository-metrics-renderer", + "X-GitHub-Api-Version": API_VERSION, + }, + ) + with urlopen(request, timeout=30) as response: # noqa: S310 - fixed GitHub API origin + return json.load(response), response.headers + + +def fetch_snapshot(repository: str, token: str) -> dict[str, Any]: + if not token: + raise RuntimeError("GITHUB_TOKEN is required when --snapshot is not supplied") + if not re.fullmatch(r"[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+", repository): + raise ValueError("repository must use owner/name format") + + encoded = "/".join(quote(part, safe="") for part in repository.split("/", 1)) + repo, _ = _request_json(f"/repos/{encoded}", token) + views, _ = _request_json(f"/repos/{encoded}/traffic/views", token) + clones, _ = _request_json(f"/repos/{encoded}/traffic/clones", token) + _, commit_headers = _request_json(f"/repos/{encoded}/commits?per_page=1", token) + + link = commit_headers.get("Link", "") + last_page = re.search(r"[?&]page=(\d+)[^>]*>; rel=\"last\"", link) + commit_count = int(last_page.group(1)) if last_page else 1 + + starred_at: list[str] = [] + page = 1 + while True: + entries, _ = _request_json( + f"/repos/{encoded}/stargazers?per_page=100&page={page}", + token, + accept="application/vnd.github.star+json", + ) + starred_at.extend( + item["starred_at"] + for item in entries + if isinstance(item, dict) and isinstance(item.get("starred_at"), str) + ) + if len(entries) < 100: + break + page += 1 + + return { + "repository": repository, + "created_at": repo["created_at"], + "generated_at": datetime.now(timezone.utc).isoformat(timespec="seconds").replace("+00:00", "Z"), + "stars": int(repo["stargazers_count"]), + "forks": int(repo["forks_count"]), + "commits": commit_count, + "unique_visitors_14d": int(views["uniques"]), + "views_14d": int(views["count"]), + "unique_cloners_14d": int(clones["uniques"]), + "clones_14d": int(clones["count"]), + "starred_at": sorted(starred_at), + } + + +def _parse_time(value: str) -> datetime: + return datetime.fromisoformat(value.replace("Z", "+00:00")).astimezone(timezone.utc) + + +def _compact(value: int) -> str: + if value >= 1_000_000: + return f"{value / 1_000_000:.1f}m" + if value >= 1_000: + return f"{value / 1_000:.1f}k" + return str(value) + + +def render_svg(snapshot: dict[str, Any]) -> str: + repository = escape(str(snapshot["repository"])) + created = _parse_time(str(snapshot["created_at"])) + generated = _parse_time(str(snapshot["generated_at"])) + if generated <= created: + generated = created.replace(microsecond=0) + + stars = max(0, int(snapshot["stars"])) + star_dates = sorted( + date for date in (_parse_time(str(value)) for value in snapshot.get("starred_at", [])) + if created <= date <= generated + ) + + chart_x, chart_y, chart_w, chart_h = 64.0, 150.0, 540.0, 292.0 + baseline = chart_y + chart_h + seconds = max((generated - created).total_seconds(), 1.0) + y_max = max(stars, 1) + + def x_for(date: datetime) -> float: + return chart_x + chart_w * max(0.0, min(1.0, (date - created).total_seconds() / seconds)) + + def y_for(value: int) -> float: + return baseline - chart_h * max(0.0, min(1.0, value / y_max)) + + path = [f"M {chart_x:.1f} {y_for(0):.1f}"] + count = 0 + for date in star_dates: + x = x_for(date) + path.append(f"L {x:.1f} {y_for(count):.1f}") + count += 1 + path.append(f"L {x:.1f} {y_for(count):.1f}") + if count < stars: + path.append(f"L {chart_x + chart_w:.1f} {y_for(stars):.1f}") + else: + path.append(f"L {chart_x + chart_w:.1f} {y_for(count):.1f}") + line_path = " ".join(path) + area_path = f"{line_path} L {chart_x + chart_w:.1f} {baseline:.1f} Z" + + ticks = sorted({0, max(1, math.ceil(y_max / 2)), y_max}) + grid = [] + for value in ticks: + y = y_for(value) + grid.append( + f'