#!/usr/bin/env python3
"""Generate a self-hosted star-history SVG using the GitHub API."""
from __future__ import annotations
import argparse
import base64
import concurrent.futures
import datetime as dt
import json
import math
import os
import urllib.error
import urllib.request
from collections import Counter
from pathlib import Path
from xml.sax.saxutils import escape
GITHUB_API = "https://api.github.com"
def github_get(path: str, token: str) -> object:
request = urllib.request.Request(
f"{GITHUB_API}{path}",
headers={
"Accept": "application/vnd.github.star+json",
"Authorization": f"Bearer {token}",
"User-Agent": "cangjie-skill-star-history",
"X-GitHub-Api-Version": "2026-03-10",
},
)
try:
with urllib.request.urlopen(request, timeout=30) as response:
return json.load(response)
except urllib.error.HTTPError as exc:
message = exc.read().decode("utf-8", errors="replace")
raise RuntimeError(f"GitHub API returned HTTP {exc.code}: {message}") from exc
def fetch_repo_data(repo: str, token: str) -> tuple[list[dt.date], str]:
repo_data = github_get(f"/repos/{repo}", token)
if not isinstance(repo_data, dict) or not isinstance(repo_data.get("stargazers_count"), int):
raise RuntimeError("Unexpected response from GitHub's repository API")
page_count = max(math.ceil(repo_data["stargazers_count"] / 100), 1)
def fetch_page(page: int) -> object:
return github_get(f"/repos/{repo}/stargazers?per_page=100&page={page}", token)
dates: list[dt.date] = []
with concurrent.futures.ThreadPoolExecutor(max_workers=min(8, page_count)) as executor:
pages = executor.map(fetch_page, range(1, page_count + 1))
for items in pages:
if not isinstance(items, list):
raise RuntimeError("Unexpected response from GitHub's stargazers API")
for item in items:
dates.append(dt.datetime.fromisoformat(item["starred_at"].replace("Z", "+00:00")).date())
owner = repo_data.get("owner")
avatar_url = owner.get("avatar_url", "") if isinstance(owner, dict) else ""
return sorted(dates), avatar_url
def fetch_image_data_uri(url: str) -> str:
if not url:
return ""
separator = "&" if "?" in url else "?"
request = urllib.request.Request(
f"{url}{separator}s=64",
headers={"User-Agent": "cangjie-skill-star-history"},
)
try:
with urllib.request.urlopen(request, timeout=30) as response:
media_type = response.headers.get_content_type()
encoded = base64.b64encode(response.read()).decode("ascii")
return f"data:{media_type};base64,{encoded}"
except (urllib.error.HTTPError, urllib.error.URLError, TimeoutError):
return ""
def nice_tick_step(value: int, tick_count: int = 5) -> int:
target = max(value / max(tick_count - 1, 1), 1)
magnitude = 10 ** math.floor(math.log10(target))
candidates = [magnitude * factor for factor in (1, 2, 5, 10)]
return max(1, int(min(candidates, key=lambda candidate: abs(candidate - target))))
def format_star_tick(value: int) -> str:
if value >= 1000:
return f"{value / 1000:g}K"
return str(value)
def render_svg(
repo: str,
dates: list[dt.date],
avatar_data_uri: str,
font_base64: str,
logo_base64: str,
) -> str:
if not dates:
raise RuntimeError("No star history was returned for this repository")
width, height = 800, 533.333
left, right, top, bottom = 70, 30, 60, 50
plot_width = width - left - right
plot_height = height - top - bottom
start = dates[0]
end = max(dates[-1], dt.datetime.now(dt.timezone.utc).date())
day_span = max((end - start).days, 1)
daily = Counter(dates)
series: list[tuple[dt.date, int]] = []
total = 0
for offset in range(day_span + 1):
day = start + dt.timedelta(days=offset)
total += daily[day]
series.append((day, total))
y_max = max(total, 1)
def x(day: dt.date) -> float:
return ((day - start).days / day_span) * plot_width
def y(value: int) -> float:
return plot_height - (value / y_max) * plot_height
line_path = " ".join(
f"{'M' if index == 0 else 'L'}{x(day):.3f} {y(value):.3f}"
for index, (day, value) in enumerate(series)
)
y_ticks: list[str] = []
tick_step = nice_tick_step(total)
for value in range(tick_step, total + 1, tick_step):
ypos = y(value)
y_ticks.append(
''
f''
f'{format_star_tick(value)}'
''
)
x_ticks: list[str] = []
for fraction in (0.20, 0.43, 0.67, 0.90):
day = start + dt.timedelta(days=round(day_span * fraction))
xpos = x(day)
x_ticks.append(
f''
f'{day.strftime("%b %d")}'
)
safe_repo = escape(repo)
legend_width = max(150, len(repo) * 7.5 + 29)
avatar_markup = ""
if avatar_data_uri:
avatar_markup = f'''
'''
return f'''
'''
def main() -> None:
parser = argparse.ArgumentParser()
parser.add_argument("--repo", default=os.environ.get("GITHUB_REPOSITORY", "kangarooking/cangjie-skill"))
parser.add_argument("--output", type=Path, default=Path("assets/star-history.svg"))
args = parser.parse_args()
token = os.environ.get("GITHUB_TOKEN") or os.environ.get("GH_TOKEN")
if not token:
raise SystemExit("Set GITHUB_TOKEN or GH_TOKEN before running this script")
dates, avatar_url = fetch_repo_data(args.repo, token)
avatar_data_uri = fetch_image_data_uri(avatar_url)
assets_dir = Path(__file__).resolve().parents[1] / "assets"
font_base64 = (assets_dir / "xkcd.woff.b64").read_text(encoding="ascii").strip()
logo_base64 = (assets_dir / "star-history-logo.png.b64").read_text(encoding="ascii").strip()
args.output.parent.mkdir(parents=True, exist_ok=True)
args.output.write_text(
render_svg(args.repo, dates, avatar_data_uri, font_base64, logo_base64),
encoding="utf-8",
)
print(f"Wrote {args.output} with {len(dates)} stars")
if __name__ == "__main__":
main()