#!/usr/bin/env python3 """Generate a self-hosted "Stargazers over time" SVG chart. GitHub now restricts the stargazers endpoint to a repository's own admins and collaborators, so third-party live-chart services (star-history free tier, starchart.cc, ...) return "Requires authentication" for everyone. This script fetches the star data with an authenticated token (in CI, the repo's own GITHUB_TOKEN works because it can read the repo's own stargazers) and renders a static, theme-aware SVG committed to docs/star-history.svg. Usage: GITHUB_TOKEN=xxx python scripts/generate_star_history.py # local test: GITHUB_TOKEN=$(gh auth token) python scripts/generate_star_history.py """ import os import sys from datetime import datetime, timezone import requests REPO = os.environ.get("STAR_HISTORY_REPO", "davila7/claude-code-templates") OUTPUT = os.environ.get("STAR_HISTORY_OUTPUT", "docs/star-history.svg") PER_PAGE = 100 MAX_PAGES = 400 # GitHub caps stargazers pagination at 400 pages (40k stars) WIDTH, HEIGHT = 800, 400 PAD_L, PAD_R, PAD_T, PAD_B = 70, 55, 50, 55 def fetch_stargazers(token): """Return a sorted list of datetime objects, one per star.""" session = requests.Session() session.headers.update({ "Authorization": f"Bearer {token}", "Accept": "application/vnd.github.star+json", "X-GitHub-Api-Version": "2022-11-28", "User-Agent": "claude-code-templates-star-history", }) dates = [] for page in range(1, MAX_PAGES + 1): resp = session.get( f"https://api.github.com/repos/{REPO}/stargazers", params={"per_page": PER_PAGE, "page": page}, timeout=30, ) if resp.status_code != 200: raise SystemExit( f"GitHub API error {resp.status_code} on page {page}: {resp.text[:200]}" ) batch = resp.json() if not batch: break for entry in batch: starred_at = entry.get("starred_at") if starred_at: dates.append( datetime.strptime(starred_at, "%Y-%m-%dT%H:%M:%SZ").replace( tzinfo=timezone.utc ) ) if len(batch) < PER_PAGE: break dates.sort() return dates def build_series(dates, max_points=100): """Cumulative (timestamp, count) points, downsampled to max_points.""" total = len(dates) if total == 0: return [] points = [(d, i + 1) for i, d in enumerate(dates)] if total <= max_points: return points step = total / max_points sampled = [points[int(i * step)] for i in range(max_points)] sampled.append(points[-1]) # always include the latest point return sampled def esc(text): return ( str(text) .replace("&", "&") .replace("<", "<") .replace(">", ">") ) def render_svg(series, repo): if not series: raise SystemExit("No stargazer data to render.") t_min = series[0][0].timestamp() t_max = series[-1][0].timestamp() c_max = series[-1][1] t_span = max(t_max - t_min, 1) plot_w = WIDTH - PAD_L - PAD_R plot_h = HEIGHT - PAD_T - PAD_B def x(ts): return PAD_L + (ts - t_min) / t_span * plot_w def y(count): return PAD_T + plot_h - (count / c_max) * plot_h pts = [(x(ts.timestamp()), y(c)) for ts, c in series] line = " ".join(f"{px:.1f},{py:.1f}" for px, py in pts) area = ( f"{PAD_L:.1f},{PAD_T + plot_h:.1f} " + line + f" {pts[-1][0]:.1f},{PAD_T + plot_h:.1f}" ) # Y axis ticks (5 gridlines) y_ticks = [] for i in range(5): val = round(c_max * i / 4) y_ticks.append((y(val), val)) # X axis ticks (dates) x_ticks = [] for i in range(5): ts = t_min + t_span * i / 4 label = datetime.fromtimestamp(ts, tz=timezone.utc).strftime("%b %Y") x_ticks.append((x(ts), label)) grid_lines = "" for gy, val in y_ticks: grid_lines += ( f'\n' f'{val:,}\n' ) x_labels = "" for gx, label in x_ticks: x_labels += ( f'{esc(label)}\n' ) updated = datetime.now(tz=timezone.utc).strftime("%Y-%m-%d") return f''' Stargazers over time {esc(repo)} · {c_max:,} stars · updated {updated} {grid_lines} {x_labels} ''' def main(): token = os.environ.get("GITHUB_TOKEN") if not token: raise SystemExit("GITHUB_TOKEN is required (repo read access to stargazers).") print(f"Fetching stargazers for {REPO} ...", file=sys.stderr) dates = fetch_stargazers(token) print(f"Got {len(dates)} stars.", file=sys.stderr) series = build_series(dates) svg = render_svg(series, REPO) os.makedirs(os.path.dirname(OUTPUT), exist_ok=True) with open(OUTPUT, "w", encoding="utf-8") as fh: fh.write(svg) print(f"Wrote {OUTPUT} ({len(svg)} bytes).", file=sys.stderr) if __name__ == "__main__": main()