File size: 7,314 Bytes
108e227 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 | #!/usr/bin/env python3
"""Refresh GitHub star counts for plugin-catalog repos, at most once a day.
Writes ``website/static/api/plugin-stars.json``::
{"fetched_at": "<ISO-8601 UTC>", "stars": {"owner/repo": 123, ...}}
``extract-plugins.py`` merges these into ``plugins.json`` so the catalog page can rank
entries by stars.
Rate-limit discipline (the whole point of this file): the docs site deploys many times a
day and GitHub's per-installation API budget is shared with every other workflow. So this
script NEVER calls the GitHub API unless the cache is stale:
1. Download the live site's current ``plugin-stars.json`` (one CDN GET, not the API).
2. If its ``fetched_at`` is younger than ``--max-age-hours`` (default 24), write it back to
disk unchanged and exit. Zero GitHub calls.
3. Otherwise probe ``GET /repos/{owner}/{repo}`` once per unique repo. A 403/429 or any
network error keeps the previous count for that repo rather than dropping it.
Local ``npm run build`` without network or token degrades to whatever is on disk / an
empty map; the page then simply ranks alphabetically.
"""
from __future__ import annotations
import argparse
import json
import os
import re
import sys
import urllib.error
import urllib.request
from datetime import datetime, timedelta, timezone
from pathlib import Path
import yaml
REPO_ROOT = Path(__file__).resolve().parents[2]
DEFAULT_CATALOG_DIR = REPO_ROOT / "plugin-catalog"
DEFAULT_OUTPUT = REPO_ROOT / "website" / "static" / "api" / "plugin-stars.json"
LIVE_URL = "https://hermes-agent.nousresearch.com/docs/api/plugin-stars.json"
_GITHUB_REPO_RE = re.compile(r"^https://github\.com/([^/\s]+)/([^/\s#?]+?)(?:\.git)?/?$")
def _log(msg: str) -> None:
print(f"[fetch-plugin-stars] {msg}", file=sys.stderr)
def github_slug(repo_url: str) -> str | None:
"""``owner/repo`` for a github.com URL, else None (non-GitHub hosts are never probed)."""
m = _GITHUB_REPO_RE.match(repo_url.strip())
return f"{m.group(1)}/{m.group(2)}" if m else None
def catalog_slugs(catalog_dir: Path) -> list[str]:
slugs: set[str] = set()
for path in sorted(catalog_dir.glob("*.yaml")):
if path.name == "removed.yaml":
continue
try:
raw = yaml.safe_load(path.read_text(encoding="utf-8"))
except (yaml.YAMLError, OSError):
continue
slug = github_slug(str((raw or {}).get("repo") or "")) if isinstance(raw, dict) else None
if slug:
slugs.add(slug)
return sorted(slugs)
def _http_json(url: str, headers: dict[str, str], timeout: float = 15.0):
req = urllib.request.Request(url, headers={"User-Agent": "hermes-agent-docs", **headers})
with urllib.request.urlopen(req, timeout=timeout) as resp:
return json.loads(resp.read().decode("utf-8"))
def load_previous(output: Path, live_url: str | None) -> dict:
"""Newest of {live site copy, on-disk copy}; ``{}`` when neither exists."""
candidates: list[dict] = []
if live_url:
try:
data = _http_json(live_url, {})
if isinstance(data, dict) and isinstance(data.get("stars"), dict):
candidates.append(data)
except (urllib.error.URLError, OSError, ValueError) as e:
_log(f"live cache unavailable ({e}); continuing without it")
if output.is_file():
try:
data = json.loads(output.read_text(encoding="utf-8"))
if isinstance(data, dict) and isinstance(data.get("stars"), dict):
candidates.append(data)
except (OSError, ValueError):
pass
return max(candidates, key=lambda d: str(d.get("fetched_at") or ""), default={})
def is_fresh(previous: dict, max_age: timedelta, now: datetime) -> bool:
try:
fetched = datetime.fromisoformat(str(previous.get("fetched_at")))
except (TypeError, ValueError):
return False
if fetched.tzinfo is None:
fetched = fetched.replace(tzinfo=timezone.utc)
return now - fetched < max_age
def probe_stars(slugs: list[str], previous: dict[str, int], token: str | None) -> dict[str, int]:
"""One ``GET /repos/{slug}`` each; on any failure keep the previous count (never regress to 0)."""
headers = {"Accept": "application/vnd.github+json"}
if token:
headers["Authorization"] = f"Bearer {token}"
stars: dict[str, int] = {}
rate_limited = False
for slug in slugs:
if rate_limited:
if slug in previous:
stars[slug] = previous[slug]
continue
try:
data = _http_json(f"https://api.github.com/repos/{slug}", headers)
stars[slug] = int(data.get("stargazers_count") or 0)
except urllib.error.HTTPError as e:
if e.code in (403, 429):
_log(f"rate limited at {slug} (HTTP {e.code}); keeping previous counts for the rest")
rate_limited = True
else:
_log(f"{slug}: HTTP {e.code}; keeping previous count")
if slug in previous:
stars[slug] = previous[slug]
except (urllib.error.URLError, OSError, ValueError) as e:
_log(f"{slug}: {e}; keeping previous count")
if slug in previous:
stars[slug] = previous[slug]
return stars
def main(catalog_dir: Path = DEFAULT_CATALOG_DIR, output: Path = DEFAULT_OUTPUT,
max_age_hours: float = 24.0, force: bool = False, live_url: str | None = LIVE_URL,
token: str | None = None) -> int:
now = datetime.now(timezone.utc)
previous = load_previous(output, live_url)
output.parent.mkdir(parents=True, exist_ok=True)
if not force and is_fresh(previous, timedelta(hours=max_age_hours), now):
output.write_text(json.dumps(previous, separators=(",", ":")), encoding="utf-8")
print(f"Reused plugin stars from {previous.get('fetched_at')} "
f"({len(previous.get('stars', {}))} repos, no GitHub calls)")
return 0
slugs = catalog_slugs(catalog_dir)
prev_stars = {k: int(v) for k, v in (previous.get("stars") or {}).items() if isinstance(v, (int, float))}
stars = probe_stars(slugs, prev_stars, token or os.environ.get("GITHUB_TOKEN") or os.environ.get("GH_TOKEN"))
fetched_at = now.isoformat() if stars else str(previous.get("fetched_at") or "")
output.write_text(json.dumps({"fetched_at": fetched_at, "stars": stars}, separators=(",", ":")),
encoding="utf-8")
print(f"Probed {len(slugs)} repos, wrote {len(stars)} star counts to {output}")
return 0
if __name__ == "__main__":
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--catalog-dir", type=Path, default=DEFAULT_CATALOG_DIR)
parser.add_argument("--output", type=Path, default=DEFAULT_OUTPUT)
parser.add_argument("--max-age-hours", type=float, default=24.0)
parser.add_argument("--force", action="store_true", help="probe GitHub even if the cache is fresh")
parser.add_argument("--no-live", action="store_true", help="do not consult the live site's cache")
args = parser.parse_args()
sys.exit(main(catalog_dir=args.catalog_dir, output=args.output, max_age_hours=args.max_age_hours,
force=args.force, live_url=None if args.no_live else LIVE_URL))
|