www.git

www

~ Personal site — projects, posts, about. Built with sqzass.
git clone https://github.com/debuman/www.git

tools/stars.py (4233B)

      1 #!/usr/bin/env python3
      2 """프로젝트의 GitHub 별 개수를 front matter 에 적어 넣는다.
      3 
      4 **왜 빌드 시점인가.** 브라우저에서 부르면 익명 GitHub API 는 IP 당 시간당
      5 60회다. 한국은 CGNAT 이 흔해서 한 사람이 아니라 한 회선이 그 한도를 나눠 쓰고,
      6 걸리면 별이 전부 빈칸으로 남는다. 그리고 sqzass 는 같은 입력이면 같은 바이트를
      7 내야 하는데, 렌더 중에 네트워크를 부르면 그 보장이 깨진다.
      8 
      9 그래서 숫자를 레포에 커밋해 둔다. 빌드는 파일만 읽으므로 재현되고, 값은
     10 `.github/workflows/stars.yml` 이 하루 한 번 갱신한다.
     11 
     12 0 이어도 적는다. 그게 사실이고, 없는 항목만 숫자가 빠지면 줄이 어긋난다.
     13 
     14     python3 tools/stars.py            # 갱신
     15     python3 tools/stars.py --check    # 모든 repo 항목에 숫자가 있는지 확인
     16 """
     17 
     18 import json
     19 import re
     20 import subprocess
     21 import sys
     22 import urllib.request
     23 from pathlib import Path
     24 
     25 ROOT = Path(__file__).resolve().parent.parent
     26 # content/ 전체를 훑는다. 별 개수는 프로젝트 상세 페이지에도 있고 홈의 모자이크
     27 # 카탈로그에도 있는데, 후자를 빼 두면 홈의 숫자만 조용히 낡는다.
     28 CONTENT = ROOT / "content"
     29 
     30 # `repo<공백>= "https://github.com/owner/name"` — 뒤따르는 stars 줄까지 함께 잡아
     31 # 갈아 끼운다. 없으면 새로 붙인다.
     32 LINE = re.compile(
     33     r'^(?P<indent>[ \t]*)repo(?P<pad>\s*)= "https://github\.com/(?P<slug>[^/"]+/[^/"]+)"'
     34     r'(?P<old>\n[ \t]*stars\s*=\s*\d+)?',
     35     re.M,
     36 )
     37 
     38 
     39 def fetch(slug: str) -> int | None:
     40     """gh 가 있으면 그걸 쓴다 — 인증 요청은 5000/시간이라 CI 에서 안 걸린다."""
     41     try:
     42         out = subprocess.run(
     43             ["gh", "api", f"repos/{slug}", "--jq", ".stargazers_count"],
     44             capture_output=True, text=True, timeout=20,
     45         )
     46         if out.returncode == 0 and out.stdout.strip().isdigit():
     47             return int(out.stdout.strip())
     48     except (OSError, subprocess.SubprocessError):
     49         pass
     50     try:
     51         req = urllib.request.Request(
     52             f"https://api.github.com/repos/{slug}",
     53             headers={"Accept": "application/vnd.github+json", "User-Agent": "sqzer.com"},
     54         )
     55         with urllib.request.urlopen(req, timeout=20) as r:
     56             return int(json.load(r)["stargazers_count"])
     57     except Exception as e:
     58         print(f"  {slug}: {e}", file=sys.stderr)
     59         return None
     60 
     61 
     62 def check() -> int:
     63     bad = [
     64         f"{p.relative_to(ROOT)} ({m.group('slug')})"
     65         for p in sorted(CONTENT.rglob("*.md"))
     66         for m in LINE.finditer(p.read_text(encoding="utf-8"))
     67         if not m.group("old")
     68     ]
     69     if bad:
     70         print("별 개수가 없는 항목:\n  " + "\n  ".join(bad) +
     71               "\npython3 tools/stars.py 를 돌리세요.", file=sys.stderr)
     72         return 1
     73     print("ok — 모든 프로젝트에 별 개수가 있습니다")
     74     return 0
     75 
     76 
     77 def main() -> int:
     78     cache: dict[str, int | None] = {}
     79     changed = 0
     80 
     81     for path in sorted(CONTENT.rglob("*.md")):
     82         text = path.read_text(encoding="utf-8")
     83 
     84         def swap(m: re.Match) -> str:
     85             slug = m.group("slug")
     86             if slug not in cache:
     87                 cache[slug] = fetch(slug)
     88             n = cache[slug]
     89             if n is None:
     90                 return m.group(0)  # 못 가져오면 있던 값을 지우지 않는다
     91             keep = m.group(0)[: m.start("old") - m.start()] if m.group("old") else m.group(0)
     92             # `stars` 가 `repo` 보다 한 글자 길다. 여백을 그대로 베끼면 = 열이
     93             # 한 칸 밀린다 — 이 카탈로그들은 = 를 맞춰 적는 손글씨 TOML 이다.
     94             pad = m.group("pad")
     95             return f'{keep}\n{m.group("indent")}stars{pad[1:] or " "}= {n}'
     96 
     97         new = LINE.sub(swap, text)
     98         if new != text:
     99             path.write_text(new, encoding="utf-8")
    100             changed += 1
    101 
    102     for slug, n in sorted(cache.items()):
    103         print(f"  {slug:28} {n if n is not None else '실패'}")
    104     print(f"{changed}개 파일 갱신")
    105     return 0
    106 
    107 
    108 if __name__ == "__main__":
    109     sys.exit(check() if "--check" in sys.argv else main())