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())