252 lines
9.3 KiB
Python
252 lines
9.3 KiB
Python
#!/usr/bin/env python3
|
|
"""Generate profile stats cards (SVG) for a Gitea/Forgejo user profile README.
|
|
|
|
Stdlib only (no pip dependencies). Pulls public data from the Gitea API and
|
|
renders two cards:
|
|
|
|
stats/overview.svg - repos, stars earned, forks, followers, stars given
|
|
stats/languages.svg - top languages bar + legend
|
|
|
|
Configuration via environment variables:
|
|
GIT_URL base URL of the instance (default: https://git.turbo-data.com)
|
|
USER username to generate cards for
|
|
ACCESS_TOKEN optional API token ("read:user" + "read:repository" scopes);
|
|
omit if the profile is fully public
|
|
OUTPUT_DIR where to write the cards (default: stats)
|
|
|
|
Cards use a transparent background and a neutral palette that stays readable
|
|
in both Gitea light and dark themes.
|
|
"""
|
|
|
|
import json
|
|
import os
|
|
import sys
|
|
import urllib.parse
|
|
import urllib.request
|
|
from datetime import datetime, timezone
|
|
|
|
GIT_URL = os.environ.get("GIT_URL", "https://git.turbo-data.com").rstrip("/")
|
|
USER = os.environ.get("USER", "") or sys.exit("USER env var not set")
|
|
TOKEN = os.environ.get("ACCESS_TOKEN", "") # optional
|
|
OUT = os.environ.get("OUTPUT_DIR", "stats")
|
|
|
|
UA = "gitea-profile-stats/1.0"
|
|
API = GIT_URL + "/api/v1"
|
|
|
|
# Neutral palette readable on both light (#fff) and dark (#0d1117) backgrounds
|
|
INK = "#768390" # secondary text
|
|
INK_STRONG = "#a5acaf" # primary text
|
|
ACCENT = "#4493f8"
|
|
STROKE = "#768390"
|
|
MAX_LANGS = 5
|
|
|
|
# Small fallback language color table (used if remote colors fetch fails)
|
|
FALLBACK_COLORS = {
|
|
"Python": "#3572A5", "TypeScript": "#3178c6", "JavaScript": "#f1e05a",
|
|
"Shell": "#89e051", "Go": "#00ADD8", "Rust": "#dea584", "C": "#555555",
|
|
"C++": "#f34b7d", "Java": "#b07219", "HTML": "#e34c26", "CSS": "#563d7c",
|
|
"SQL": "#e38c00", "Dockerfile": "#384d54", "Makefile": "#427819",
|
|
"JSON": "#404e5c", "Markdown": "#083fa1", "YAML": "#cb171e",
|
|
"Ruby": "#701516", "PHP": "#4F5D95", "Lua": "#000080",
|
|
"SCSS": "#c6538c", "Vue": "#41b883", "Dart": "#00B4AB", "Kotlin": "#A97BFF",
|
|
}
|
|
|
|
|
|
def api(path, params=None):
|
|
"""GET {API}{path} -> (json_body, response_headers) or (None, None) on failure."""
|
|
url = f"{API}{path}"
|
|
if params:
|
|
url += "?" + urllib.parse.urlencode(params)
|
|
req = urllib.request.Request(url, headers={"User-Agent": UA})
|
|
if TOKEN:
|
|
req.add_header("Authorization", f"token {TOKEN}")
|
|
try:
|
|
with urllib.request.urlopen(req, timeout=15) as r:
|
|
return json.load(r), dict(r.headers)
|
|
except Exception: # noqa: BLE001 - some endpoints 401 without a token
|
|
return None, None
|
|
|
|
|
|
def total_count(path, params=None):
|
|
"""Count a list endpoint via X-Total-Count, using a cheap page-1 call.
|
|
|
|
Returns None when the endpoint is not available (e.g. 401 without a token).
|
|
"""
|
|
_body, headers = api(path, dict(params or {}, limit=1))
|
|
if not headers:
|
|
return None
|
|
return int(headers.get("x-total-count", 0))
|
|
|
|
|
|
def esc(s):
|
|
return (
|
|
str(s).replace("&", "&").replace("<", "<")
|
|
.replace(">", ">").replace('"', """)
|
|
)
|
|
|
|
|
|
def fetch():
|
|
user, _ = api(f"/users/{urllib.parse.quote(USER)}")
|
|
repos, _ = api(f"/users/{urllib.parse.quote(USER)}/repos", {"limit": 50})
|
|
|
|
data = {
|
|
"name": user.get("full_name") or user.get("login", USER),
|
|
"login": user.get("login", USER),
|
|
"visit_year": user.get("created", "")[:4],
|
|
"repositories": user.get("public_repos", len(repos)),
|
|
"followers": total_count(f"/users/{urllib.parse.quote(USER)}/followers"),
|
|
"following": total_count(f"/users/{urllib.parse.quote(USER)}/following"),
|
|
}
|
|
|
|
stars = forks = 0
|
|
by_lang = {}
|
|
for r in repos:
|
|
stars += r.get("stars_count", 0) or 0
|
|
forks += r.get("forks_count", 0) or 0
|
|
lang = (r.get("language") or "").strip()
|
|
if lang:
|
|
weight = r.get("size") or 1 # primary-language weight by repo size
|
|
by_lang[lang] = by_lang.get(lang, 0) + weight
|
|
data.update(stars=stars, forks=forks, languages=by_lang,
|
|
starred=total_count(f"/users/{urllib.parse.quote(USER)}/starred"))
|
|
return data
|
|
|
|
|
|
def colors_for(languages):
|
|
colors = {}
|
|
try:
|
|
req = urllib.request.Request(
|
|
"https://raw.githubusercontent.com/ozh/github-colors/master/colors.json",
|
|
headers={"User-Agent": UA},
|
|
)
|
|
with urllib.request.urlopen(req, timeout=10) as r:
|
|
colors = {k: "#" + v["color"] for k, v in json.load(r).items()
|
|
if k in languages}
|
|
except Exception: # noqa: BLE001 - offline instances still need cards
|
|
pass
|
|
autofill = [f"#{(abs(hash(l)) % 0xAAAAAA) + 0x555555:06x}"
|
|
for i, l in enumerate(languages)]
|
|
return {l: colors.get(l, FALLBACK_COLORS.get(l, c))
|
|
for l, c in zip(languages, autofill)}
|
|
|
|
|
|
def svg_head(w, h):
|
|
return (f'<svg xmlns="http://www.w3.org/2000/svg" width="{w}" '
|
|
f'height="{h}" role="img" aria-label="profile stats">'
|
|
'<style>text{font-family:-apple-system,BlinkMacSystemFont,'
|
|
'"Segoe UI",Helvetica,Arial,sans-serif;}</style>')
|
|
|
|
|
|
def stat_items(d):
|
|
return [
|
|
("Repositories", d["repositories"]),
|
|
("Stars earned", d["stars"]),
|
|
("Forks", d["forks"]),
|
|
("Followers", d["followers"]),
|
|
("Stars given", d["starred"]),
|
|
("Member since", d["visit_year"] or "—"),
|
|
]
|
|
|
|
|
|
def draw_overview(d):
|
|
items = stat_items(d)
|
|
cells = []
|
|
for i, (label, value) in enumerate(items):
|
|
x = 26 + (i % 2) * 228
|
|
y = 84 + (i // 2) * 44
|
|
cells.append(
|
|
f'<g><rect x="{x}" y="{y - 15}" width="11" height="11" rx="2.5" '
|
|
f'fill="{ACCENT}" opacity="0.85"/>'
|
|
f'<text x="{x + 20}" y="{y}" font-size="13" fill="{INK}">{esc(label)}</text>'
|
|
f'<text x="{x + 192}" y="{y}" font-size="13" text-anchor="end" '
|
|
f'fill="{INK_STRONG}" font-weight="600">{esc(value if value is not None else "—")}</text></g>'
|
|
)
|
|
return (
|
|
svg_head(480, 200)
|
|
+ f'<rect x="1.5" y="1.5" width="477" height="197" rx="12" fill="none" '
|
|
f'stroke="{STROKE}" stroke-opacity="0.35" stroke-width="1.5"/>'
|
|
+ f'<text x="26" y="44" font-size="18" font-weight="700" fill="{ACCENT}">'
|
|
+ esc(d["name"]) + "</text>"
|
|
+ f'<text x="454" y="44" font-size="12" fill="{INK}" text-anchor="end">'
|
|
+ esc(f"Gitea · @{d['login']}") + "</text>"
|
|
+ "".join(cells)
|
|
+ f'<text x="26" y="188" font-size="10" fill="{INK}" opacity="0.7">'
|
|
+ esc("updated " + datetime.now(timezone.utc).strftime("%Y-%m-%d"))
|
|
+ "</text></svg>"
|
|
)
|
|
|
|
|
|
def draw_languages(d):
|
|
langs = sorted(d["languages"].items(), key=lambda kv: -kv[1])[:MAX_LANGS]
|
|
cells = []
|
|
if langs:
|
|
total = sum(v for _, v in langs)
|
|
colors = colors_for([l for l, _ in langs])
|
|
x = 26
|
|
bar_w = 428
|
|
segs = []
|
|
legend = []
|
|
for lang, v in langs:
|
|
share = v / total
|
|
w = bar_w * share if share > 0.02 else max(bar_w * share, 3)
|
|
segs.append(
|
|
f'<rect x="{x:.1f}" y="66" width="{w:.1f}" height="10" rx="4" '
|
|
f'fill="{colors[lang]}"/>'
|
|
)
|
|
lx = 26 + ((len(legend) % 2) * 214)
|
|
ly = 106 + (len(legend) // 2) * 26
|
|
legend.append(
|
|
f'<g><circle cx="{lx + 5}" cy="{ly - 4}" r="5" fill="{colors[lang]}"/>'
|
|
f'<text x="{lx + 16}" y="{ly}" font-size="12" fill="{INK}">'
|
|
+ esc(lang)
|
|
+ f'</text><text x="{lx + 198}" y="{ly}" font-size="12" '
|
|
f'text-anchor="end" fill="{INK_STRONG}" font-weight="600">'
|
|
f"{share * 100:.1f}%</text></g>"
|
|
)
|
|
x += w
|
|
label = f"<text x=\"26\" y=\"42\" font-size=\"16\" font-weight=\"700\" fill=\"{INK_STRONG}\">Top languages</text>"
|
|
footer_y = 186 if len(legend) > 3 else 146
|
|
cells = (
|
|
[label]
|
|
+ [f'<rect x="{bar_x}" y="{bar_y}" width="{bar_w}" height="10" rx="5" '
|
|
f'fill="none" stroke="{STROKE}" stroke-opacity="0.35"/>'
|
|
for bar_x, bar_y in [(25.5, 65.5)]]
|
|
+ segs
|
|
+ legend
|
|
+ [f'<text x="26" y="{footer_y}" font-size="10" fill="{INK}" opacity="0.7">'
|
|
+ esc("updated " + datetime.now(timezone.utc).strftime("%Y-%m-%d"))
|
|
+ "</text>"]
|
|
)
|
|
height = footer_y + 16
|
|
else:
|
|
height = 100
|
|
cells = [
|
|
f'<text x="26" y="46" font-size="14" fill="{INK}">No public repositories '
|
|
f'with languages yet.</text>',
|
|
]
|
|
body = (
|
|
svg_head(480, height)
|
|
+ f'<rect x="1.5" y="1.5" width="477" height="{height - 3}" rx="12" '
|
|
f'fill="none" stroke="{STROKE}" stroke-opacity="0.35" stroke-width="1.5"/>'
|
|
+ "".join(cells)
|
|
+ "</svg>"
|
|
)
|
|
return body
|
|
|
|
|
|
def main():
|
|
os.makedirs(OUT, exist_ok=True)
|
|
d = fetch()
|
|
print(f"Loaded profile for {d['login']}: {d['repositories']} repos, "
|
|
f"{d['stars']} stars, {d['forks']} forks, {d['followers']} followers")
|
|
for name, svg in (("overview", draw_overview(d)),
|
|
("languages", draw_languages(d))):
|
|
path = os.path.join(OUT, f"{name}.svg")
|
|
with open(path, "w", encoding="utf-8") as f:
|
|
f.write(svg)
|
|
print(f"wrote {path}")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|