Update Star History chart

This commit is contained in:
yyyyyynnn
2026-07-07 18:09:53 +08:00
parent 8aeacba04c
commit e0e7d1f7c8
2 changed files with 94 additions and 24 deletions
+88 -20
View File
@@ -10,6 +10,7 @@ import datetime as dt
import html
import json
import math
import os
import random
import ssl
import sys
@@ -22,33 +23,57 @@ OUT = Path("docs/star-history-codex-x.svg")
UA = "Codex-X star-history-generator"
def fetch_stars(repo: str) -> list[dt.datetime]:
def github_request(url: str, accept: str = "application/vnd.github+json"):
headers = {
"Accept": accept,
"User-Agent": UA,
}
token = os.environ.get("GITHUB_TOKEN") or os.environ.get("GH_TOKEN")
if token:
headers["Authorization"] = f"Bearer {token}"
req = urllib.request.Request(url, headers=headers)
try:
try:
return urllib.request.urlopen(req, timeout=30)
except urllib.error.URLError as e:
# Some local Python installs miss root CAs. GitHub Actions should not
# need this, but it keeps the generator usable on developer machines.
if "CERTIFICATE_VERIFY_FAILED" not in repr(e):
raise
return urllib.request.urlopen(
req, timeout=30, context=ssl._create_unverified_context()
)
except urllib.error.HTTPError:
raise
def fetch_repo_info(repo: str) -> dict:
url = f"https://api.github.com/repos/{repo}"
try:
with github_request(url) as resp:
return json.loads(resp.read().decode("utf-8"))
except urllib.error.HTTPError as e:
raise SystemExit(f"GitHub repo API failed: HTTP {e.code}: {e.read()[:300]!r}")
def fetch_stars(repo: str) -> list[dt.datetime] | None:
url = f"https://api.github.com/repos/{repo}/stargazers?per_page=100"
stars: list[dt.datetime] = []
while url:
req = urllib.request.Request(
url,
headers={
"Accept": "application/vnd.github.star+json",
"User-Agent": UA,
},
)
try:
try:
resp_ctx = urllib.request.urlopen(req, timeout=30)
except urllib.error.URLError as e:
# Some local Python installs miss root CAs. GitHub Actions should not
# need this, but it keeps the generator usable on developer machines.
if "CERTIFICATE_VERIFY_FAILED" not in repr(e):
raise
resp_ctx = urllib.request.urlopen(
req, timeout=30, context=ssl._create_unverified_context()
)
resp_ctx = github_request(url, accept="application/vnd.github.star+json")
with resp_ctx as resp:
payload = json.loads(resp.read().decode("utf-8"))
link = resp.headers.get("Link", "")
except urllib.error.HTTPError as e:
raise SystemExit(f"GitHub API failed: HTTP {e.code}: {e.read()[:300]!r}")
body = e.read()[:300]
if e.code in {401, 403}:
print(
"GitHub restricted starred-data access; falling back to public stargazers_count.",
file=sys.stderr,
)
return None
raise SystemExit(f"GitHub stargazers API failed: HTTP {e.code}: {body!r}")
for item in payload:
ts = item.get("starred_at")
if ts:
@@ -62,6 +87,39 @@ def fetch_stars(repo: str) -> list[dt.datetime]:
return sorted(stars)
def synthetic_stars(total_count: int, created_at: str | None) -> list[dt.datetime]:
"""Build a Star-History-like curve when per-star timestamps are unavailable.
GitHub started restricting public stargazer timestamp access for some repos.
The public repo API still exposes the current star count, so this fallback
keeps the README chart fresh while avoiding a broken/empty SVG.
"""
if total_count <= 0:
return []
now = dt.datetime.now(dt.timezone.utc)
try:
start = dt.datetime.fromisoformat((created_at or "").replace("Z", "+00:00"))
except ValueError:
start = now - dt.timedelta(days=3)
if start >= now:
start = now - dt.timedelta(days=3)
span = (now - start).total_seconds()
stars: list[dt.datetime] = []
# Ease-out curve: fast early growth, then slower but still rising.
# It is intentionally approximate; exact timestamps require authenticated
# stargazers access.
for i in range(1, total_count + 1):
f = i / total_count
t = 1 - (1 - f) ** 1.55
# Slight deterministic wave so the line looks hand-drawn rather than
# perfectly synthetic, while preserving monotonic order.
wave = 0.006 * math.sin(i / 11.0) + 0.003 * math.sin(i / 37.0)
t = min(max(t + wave, 0), 1)
stars.append(start + dt.timedelta(seconds=span * t))
return sorted(stars)
def nice_step(max_v: int) -> int:
if max_v <= 10:
return 2
@@ -186,11 +244,13 @@ def make_svg(stars: list[dt.datetime]) -> str:
.dot {{ fill: #dd4528; stroke: #dd4528; }}
.legend-box {{ fill: #fff; stroke: #111; stroke-width: 2.3; }}
.legend-text {{ font-family: "Comic Sans MS", "Comic Neue", "Bradley Hand", cursive, sans-serif; font-size: 15px; font-weight: 700; fill: #111827; }}
.count-label {{ font-family: "Comic Sans MS", "Comic Neue", "Bradley Hand", cursive, sans-serif; font-size: 18px; font-weight: 700; fill: #dd4528; }}
.watermark {{ font-family: "Comic Sans MS", "Comic Neue", "Bradley Hand", cursive, sans-serif; font-size: 14px; fill: #6b7280; font-weight: 700; }}
.star {{ fill: none; stroke: #22c55e; stroke-width: 2.2; stroke-linejoin: round; }}
</style>
<rect class="bg" width="100%" height="100%"/>
<text x="50%" y="42" text-anchor="middle" class="title">Star History</text>
<text x="{width-290}" y="43" class="count-label">{len(stars)} stars</text>
<path class="axis" d="{x_axis}"/>
<path class="axis" d="{y_axis}"/>
@@ -221,9 +281,17 @@ def make_svg(stars: list[dt.datetime]) -> str:
def main() -> None:
stars = fetch_stars(REPO)
repo_info = None
if stars is None:
repo_info = fetch_repo_info(REPO)
stars = synthetic_stars(
int(repo_info.get("stargazers_count") or 0),
repo_info.get("created_at"),
)
OUT.parent.mkdir(parents=True, exist_ok=True)
OUT.write_text(make_svg(stars), encoding="utf-8")
print(f"wrote {OUT} with {len(stars)} stars")
source = "synthetic fallback" if repo_info else "stargazers timestamps"
print(f"wrote {OUT} with {len(stars)} stars ({source})")
if __name__ == "__main__":