Files
lyogavin--airllm/scripts/gen_star_history.py
T
2026-07-13 12:40:44 +08:00

109 lines
3.7 KiB
Python

#!/usr/bin/env python3
"""Generate a star-history chart for a GitHub repo, using the authenticated GitHub API.
Unlike the anonymous star-history.com SVG API (which is rate-limited and returns an
empty chart for large repos), this samples stargazer timestamps directly with a token,
so it works reliably for repos with tens of thousands of stars. Output is a PNG, which
always renders in GitHub markdown.
Usage:
GITHUB_TOKEN=... python scripts/gen_star_history.py [owner/repo] [output.png] [theme]
theme is "light" (default) or "dark".
"""
import datetime
import json
import math
import os
import sys
import urllib.request
REPO = sys.argv[1] if len(sys.argv) > 1 else os.environ.get("REPO", "lyogavin/airllm")
OUT = sys.argv[2] if len(sys.argv) > 2 else "assets/star-history.png"
THEME = (sys.argv[3] if len(sys.argv) > 3 else os.environ.get("THEME", "light")).lower()
TOKEN = os.environ.get("GITHUB_TOKEN") or os.environ.get("GH_TOKEN")
PER_PAGE = 100
MAX_SAMPLES = 30
THEMES = {
"light": {"bg": "#ffffff", "fg": "#24292f", "grid": "#d0d7de", "line": "#e34a4a"},
"dark": {"bg": "#0d1117", "fg": "#c9d1d9", "grid": "#30363d", "line": "#ff6b6b"},
}
def gh(url, accept="application/vnd.github+json"):
headers = {"Accept": accept, "User-Agent": "airllm-star-history"}
if TOKEN:
headers["Authorization"] = f"Bearer {TOKEN}"
req = urllib.request.Request(url, headers=headers)
with urllib.request.urlopen(req, timeout=30) as r:
return json.load(r)
def main():
info = gh(f"https://api.github.com/repos/{REPO}")
total = int(info["stargazers_count"])
if total <= 0:
raise SystemExit("repo has no stars")
max_page = max(1, math.ceil(total / PER_PAGE))
if max_page == 1:
pages = [1]
else:
n = min(MAX_SAMPLES, max_page)
pages = sorted({1, max_page} | {
round(1 + i * (max_page - 1) / (n - 1)) for i in range(n)
})
points = []
for p in pages:
data = gh(
f"https://api.github.com/repos/{REPO}/stargazers?per_page={PER_PAGE}&page={p}",
accept="application/vnd.github.star+json",
)
if not data:
continue
starred_at = data[0]["starred_at"]
cumulative = (p - 1) * PER_PAGE + 1
dt = datetime.datetime.fromisoformat(starred_at.replace("Z", "+00:00"))
points.append((dt, cumulative))
points.append((datetime.datetime.now(datetime.timezone.utc), total))
points = sorted(set(points))
if len(points) < 2:
raise SystemExit("not enough data points to plot")
import matplotlib
matplotlib.use("Agg")
import matplotlib.dates as mdates
import matplotlib.pyplot as plt
xs = [d for d, _ in points]
ys = [c for _, c in points]
c = THEMES.get(THEME, THEMES["light"])
fig, ax = plt.subplots(figsize=(10, 6))
fig.patch.set_facecolor(c["bg"])
ax.set_facecolor(c["bg"])
ax.plot(xs, ys, color=c["line"], linewidth=2.5, marker="o", markersize=4)
ax.fill_between(xs, ys, color=c["line"], alpha=0.10)
ax.set_title(f"Star History — {REPO}", fontsize=15, color=c["fg"])
ax.set_ylabel("GitHub Stars", fontsize=12, color=c["fg"])
ax.set_xlabel("Date", fontsize=12, color=c["fg"])
ax.grid(True, alpha=0.3, color=c["grid"])
ax.tick_params(colors=c["fg"])
for spine in ax.spines.values():
spine.set_color(c["grid"])
ax.xaxis.set_major_formatter(mdates.DateFormatter("%Y-%m"))
fig.autofmt_xdate()
fig.tight_layout()
os.makedirs(os.path.dirname(OUT) or ".", exist_ok=True)
fig.savefig(OUT, dpi=130, facecolor=c["bg"])
print(f"wrote {OUT} ({THEME}): {len(points)} points, {total} stars")
if __name__ == "__main__":
main()