|
22 | 22 | import re |
23 | 23 | import subprocess |
24 | 24 | import sys |
| 25 | +import os |
25 | 26 | import urllib.request |
26 | | -from datetime import datetime, timezone |
| 27 | +from datetime import datetime, timedelta, timezone |
27 | 28 | from pathlib import Path |
28 | 29 |
|
29 | 30 | ANALYTICS_URL = "https://analytics.home-assistant.io/custom_integrations.json" |
|
45 | 46 | GREEN = "#3fb950" |
46 | 47 | AMBER = "#d29922" |
47 | 48 | RED = "#f85149" |
| 49 | +STAR = "#e3b341" # GitHub star gold |
48 | 50 |
|
| 51 | +REPO_SLUG = "lasswellt/govee-homeassistant" |
49 | 52 | UA = "govee-homeassistant-status/1.0 (+https://github.com/lasswellt/govee-homeassistant)" |
50 | 53 |
|
51 | 54 |
|
@@ -299,6 +302,83 @@ def run_installs(data_dir: Path, repo_dir: Path) -> None: |
299 | 302 | f"versions_matched={len(fork_counts)}") |
300 | 303 |
|
301 | 304 |
|
| 305 | +# --------------------------------------------------------------------------- # |
| 306 | +# stars mode |
| 307 | +# --------------------------------------------------------------------------- # |
| 308 | +def fetch_stargazers(slug: str) -> list[datetime]: |
| 309 | + """All star timestamps, oldest first. Uses GITHUB_TOKEN if present.""" |
| 310 | + token = os.environ.get("GITHUB_TOKEN") or os.environ.get("GH_TOKEN") |
| 311 | + out: list[datetime] = [] |
| 312 | + page = 1 |
| 313 | + while page <= 50: # 5000-star ceiling |
| 314 | + url = f"https://api.github.com/repos/{slug}/stargazers?per_page=100&page={page}" |
| 315 | + req = urllib.request.Request(url, headers={ |
| 316 | + "User-Agent": UA, "Accept": "application/vnd.github.star+json"}) |
| 317 | + if token: |
| 318 | + req.add_header("Authorization", f"Bearer {token}") |
| 319 | + with urllib.request.urlopen(req, timeout=30) as resp: |
| 320 | + data = json.loads(resp.read().decode("utf-8")) |
| 321 | + if not data: |
| 322 | + break |
| 323 | + out += [datetime.strptime(d["starred_at"], "%Y-%m-%dT%H:%M:%SZ").replace(tzinfo=timezone.utc) |
| 324 | + for d in data if d.get("starred_at")] |
| 325 | + if len(data) < 100: |
| 326 | + break |
| 327 | + page += 1 |
| 328 | + return sorted(out) |
| 329 | + |
| 330 | + |
| 331 | +def render_stars_svg(times: list[datetime], delta30: int) -> str: |
| 332 | + w, h = 760, 180 |
| 333 | + s = card_open(w, h) |
| 334 | + pad = 20 |
| 335 | + total = len(times) |
| 336 | + s.append(txt(pad, 34, "GITHUB STARS", 11, MUTED, weight=600, spacing="1.5")) |
| 337 | + s.append(txt(pad, 92, human(total), 46, STAR, weight=700)) |
| 338 | + if delta30 > 0: |
| 339 | + s.append(txt(pad + 4, 120, f"★ +{delta30} / 30d", 12, GREEN, weight=600)) |
| 340 | + |
| 341 | + gx0, gy0, gw, gh = 220, 30, w - pad - 220, 118 |
| 342 | + if total >= 2: |
| 343 | + first = times[0] |
| 344 | + now = _now() |
| 345 | + span = (now - first).total_seconds() or 1 |
| 346 | + pts = [] |
| 347 | + for i, t in enumerate(times): |
| 348 | + px = gx0 + gw * (t - first).total_seconds() / span |
| 349 | + py = gy0 + gh - (gh - 8) * (i + 1) / total |
| 350 | + pts.append((px, py)) |
| 351 | + pts.append((gx0 + gw, gy0 + gh - (gh - 8))) # extend to "now" at full count |
| 352 | + line = " ".join(f"{x:.1f},{y:.1f}" for x, y in pts) |
| 353 | + area = f"M{gx0},{gy0 + gh} " + " ".join(f"L{x:.1f},{y:.1f}" for x, y in pts) + f" L{pts[-1][0]:.1f},{gy0 + gh} Z" |
| 354 | + s.append( |
| 355 | + f'<defs><linearGradient id="sg" x1="0" x2="0" y1="0" y2="1">' |
| 356 | + f'<stop offset="0" stop-color="{STAR}" stop-opacity="0.4"/>' |
| 357 | + f'<stop offset="1" stop-color="{STAR}" stop-opacity="0"/></linearGradient></defs>' |
| 358 | + ) |
| 359 | + s.append(f'<path d="{area}" fill="url(#sg)"/>') |
| 360 | + s.append(f'<polyline points="{line}" fill="none" stroke="{STAR}" stroke-width="2" stroke-linejoin="round"/>') |
| 361 | + s.append(f'<circle cx="{pts[-1][0]:.1f}" cy="{pts[-1][1]:.1f}" r="3.5" fill="{STAR}"/>') |
| 362 | + foot_l = f"since {first:%b %Y} · {total} stargazers" |
| 363 | + else: |
| 364 | + s.append(txt(gx0 + gw / 2, gy0 + gh / 2, "collecting…", 12, MUTED, anchor="middle")) |
| 365 | + foot_l = "collecting…" |
| 366 | + |
| 367 | + s.append(txt(pad, h - 13, foot_l, 10.5, MUTED)) |
| 368 | + s.append(txt(w - pad, h - 13, stamp(), 10.5, MUTED, anchor="end")) |
| 369 | + s.append("</svg>") |
| 370 | + return "\n".join(s) |
| 371 | + |
| 372 | + |
| 373 | +def run_stars(data_dir: Path) -> None: |
| 374 | + times = fetch_stargazers(REPO_SLUG) |
| 375 | + cutoff = _now() - timedelta(days=30) |
| 376 | + delta30 = sum(1 for t in times if t >= cutoff) |
| 377 | + write_json(data_dir / "stars.json", shields_endpoint("stars", human(len(times)), "e3b341")) |
| 378 | + (data_dir / "stars-trend.svg").write_text(render_stars_svg(times, delta30)) |
| 379 | + print(f"[stars] total={len(times)} +{delta30}/30d") |
| 380 | + |
| 381 | + |
302 | 382 | # --------------------------------------------------------------------------- # |
303 | 383 | # uptime mode |
304 | 384 | # --------------------------------------------------------------------------- # |
@@ -448,13 +528,15 @@ def run_uptime(data_dir: Path) -> None: |
448 | 528 | # --------------------------------------------------------------------------- # |
449 | 529 | def main() -> int: |
450 | 530 | ap = argparse.ArgumentParser() |
451 | | - ap.add_argument("mode", choices=["installs", "uptime"]) |
| 531 | + ap.add_argument("mode", choices=["installs", "uptime", "stars"]) |
452 | 532 | ap.add_argument("--data-dir", default="docs/badges", type=Path) |
453 | 533 | ap.add_argument("--repo-dir", default=".", type=Path) |
454 | 534 | args = ap.parse_args() |
455 | 535 | args.data_dir.mkdir(parents=True, exist_ok=True) |
456 | 536 | if args.mode == "installs": |
457 | 537 | run_installs(args.data_dir, args.repo_dir) |
| 538 | + elif args.mode == "stars": |
| 539 | + run_stars(args.data_dir) |
458 | 540 | else: |
459 | 541 | run_uptime(args.data_dir) |
460 | 542 | return 0 |
|
0 commit comments