mirror of
https://github.com/alexbelgium/hassio-addons.git
synced 2026-08-25 22:33:33 +02:00
* perf(stargazer-map): negative-cache blank locations for 90 days The lookup predicate treated a blank country as "not cached", so all 1689 blank rows of the 2652-row cache were re-queried on every weekly run -- ~1689 GitHub API calls plus ~28 minutes of the polite time.sleep(1), to re-derive the same blank answer. In a 89-user sample of those blanks, 87 (97.8%) simply have no public "location" on their profile, so the lookups fail permanently rather than transiently. Add a "last_checked" column to the CSV cache. A blank country is now only re-queried once its check date is more than 90 days old; a known country is still never re-queried; a user absent from the cache is queried immediately. Rows from the old two-column file are treated as checked on 2026-08-10, so the migration happens in the loader and the next run rewrites the CSV. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(stargazer-map): treat a missing last_checked as never checked Rows carried over from the two-column CSV are no longer backfilled to the migration date; an absent, empty or non-ISO-date last_checked now reads as "never checked" and is looked up on the next run, which stamps it. The first run after merge therefore does the ~1689-user sweep once, and only after that does the 90-day cadence take over. Also addresses the review point that a corrupted last_checked in an already three-column CSV would compare as "recent" under the lexicographic check and suppress re-checks indefinitely: load_cache() now validates the cell with datetime.date.fromisoformat and drops anything that is not a real date. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * style(stargazer-map): add the missing save_cache docstring Codacy flags C0116 on the touched function. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * style(stargazer-map): capitalize load_cache docstring (pydocstyle D403) Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * perf(stargazer-map): cap expired rechecks at 200 per run to stagger them Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(stargazer-map): cap re-checks only, never the first sweep Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
465 lines
15 KiB
Python
465 lines
15 KiB
Python
#!/usr/bin/env python3
|
||
"""
|
||
Generate a static PNG world map colour-coded by the percentage of your
|
||
stargazers that come from each country. The script maintains a CSV
|
||
in ".github/stargazer_countries.csv" cache so that locations are only looked
|
||
up once. Blank answers are cached too and retried at most every RECHECK_DAYS,
|
||
no more than MAX_RECHECKS_PER_RUN re-checks per run.
|
||
"""
|
||
|
||
import csv
|
||
import datetime
|
||
import math
|
||
import os
|
||
import sys
|
||
import time
|
||
from collections import Counter
|
||
from pathlib import Path
|
||
|
||
import plotly.graph_objects as go
|
||
import pycountry
|
||
import requests
|
||
from geopy.geocoders import Nominatim
|
||
|
||
# ---- Configuration ----------------------------------------------------------
|
||
|
||
REPO = os.getenv("REPO") # expected "owner/repo"
|
||
GITHUB_TOKEN = os.getenv("GITHUB_TOKEN") # provided by workflow
|
||
CSV_PATH = Path(".github/stargazer_countries.csv")
|
||
PNG_PATH = Path(".github/stargazer_map.png")
|
||
|
||
# ---- Cache policy -----------------------------------------------------------
|
||
# Most blank rows are permanent: the user simply has no public "location" on
|
||
# their profile. Re-asking GitHub and Nominatim for them every week is ~1700
|
||
# wasted requests per run, so a blank answer is cached too and only refreshed
|
||
# after RECHECK_DAYS. A row with no "last_checked" (i.e. written before the
|
||
# column existed) counts as never checked and is looked up once, which
|
||
# stamps it.
|
||
RECHECK_DAYS = 90
|
||
|
||
# Cap on how many already-checked rows one run may *re*-check, oldest first.
|
||
# It applies only to rows that carry a real last_checked date and have since
|
||
# expired: left uncapped, they all fall due on the same day and land as one
|
||
# spike. Rows that have never been checked -- new stargazers, and every row
|
||
# migrated from the pre-"last_checked" CSV -- are always looked up in full, so
|
||
# the first run after this lands still sweeps the whole backlog.
|
||
MAX_RECHECKS_PER_RUN = 200
|
||
|
||
# ---- Rendering theme --------------------------------------------------------
|
||
# Dark, opaque panel: GitHub does not swap the image between README themes, so
|
||
# a single background has to work in both. A dark canvas with a bright
|
||
# sequential ramp stays readable on light and dark pages alike.
|
||
BG = "#0d1117" # page / ocean
|
||
LAND = "#2b323c" # countries with zero stargazers (still visible)
|
||
BORDER = "#0d1117" # country outlines, same as background
|
||
FG = "#e6edf3" # primary text
|
||
MUTED = "#8b98a5" # secondary text
|
||
|
||
# Viridis truncated at 35 %: even a single stargazer gets a colour that is
|
||
# clearly distinct from the empty-land grey.
|
||
SCALE = ["#2c728e", "#21918c", "#35b779", "#90d743", "#fde725"]
|
||
|
||
# pycountry names that are too long / too formal for a top-5 list
|
||
SHORT_NAMES = {
|
||
"Russian Federation": "Russia",
|
||
"Korea, Republic of": "South Korea",
|
||
"Korea, Democratic People's Republic of": "North Korea",
|
||
"Iran, Islamic Republic of": "Iran",
|
||
"Taiwan, Province of China": "Taiwan",
|
||
"Viet Nam": "Vietnam",
|
||
"Moldova, Republic of": "Moldova",
|
||
"Bolivia, Plurinational State of": "Bolivia",
|
||
"Venezuela, Bolivarian Republic of": "Venezuela",
|
||
"Tanzania, United Republic of": "Tanzania",
|
||
"Syrian Arab Republic": "Syria",
|
||
}
|
||
|
||
HEADERS = {
|
||
"Authorization": f"token {GITHUB_TOKEN}",
|
||
"Accept": "application/vnd.github.v3+json",
|
||
}
|
||
GEOL = Nominatim(user_agent="gh-stargazer-map")
|
||
|
||
# Non-answers that Nominatim happily resolves to a real place: "Earth" is a
|
||
# town in Texas, "Remote" is a settlement in Oregon. Matched on the whole
|
||
# stripped, lowercased string only -- "Earth, TX" is someone's actual address
|
||
# and must still geocode.
|
||
JUNK_LOCATIONS = {
|
||
"127.0.0.1",
|
||
"/dev/null",
|
||
"anywhere",
|
||
"earth",
|
||
"everywhere",
|
||
"here",
|
||
"home",
|
||
"internet",
|
||
"localhost",
|
||
"mars",
|
||
"moon",
|
||
"n/a",
|
||
"none",
|
||
"nowhere",
|
||
"null",
|
||
"planet earth",
|
||
"remote",
|
||
"space",
|
||
"the internet",
|
||
"unknown",
|
||
"world",
|
||
"worldwide",
|
||
}
|
||
|
||
|
||
# -----------------------------------------------------------------------------
|
||
|
||
|
||
def github_paginated(url):
|
||
page = 1
|
||
while True:
|
||
resp = requests.get(f"{url}?per_page=100&page={page}", headers=HEADERS)
|
||
resp.raise_for_status()
|
||
data = resp.json()
|
||
if not data:
|
||
break
|
||
yield from data
|
||
page += 1
|
||
|
||
|
||
def fetch_stargazer_usernames():
|
||
url = f"https://api.github.com/repos/{REPO}/stargazers"
|
||
return [s["login"] for s in github_paginated(url)]
|
||
|
||
|
||
def _checked_date(value):
|
||
"""Normalise a last_checked cell: a non-ISO-date value reads as never."""
|
||
value = (value or "").strip()
|
||
try:
|
||
datetime.date.fromisoformat(value)
|
||
except ValueError:
|
||
return ""
|
||
return value
|
||
|
||
|
||
def load_cache():
|
||
"""Map each username to (country, last_checked). Reads 2- and 3-column CSVs."""
|
||
if not CSV_PATH.exists():
|
||
return {}
|
||
with CSV_PATH.open(newline="", encoding="utf-8") as f:
|
||
return {
|
||
row["username"]: (
|
||
row["country"],
|
||
_checked_date(row.get("last_checked")),
|
||
)
|
||
for row in csv.DictReader(f)
|
||
}
|
||
|
||
|
||
def save_cache(cache):
|
||
"""Write the cache back as username,country,last_checked."""
|
||
CSV_PATH.parent.mkdir(parents=True, exist_ok=True)
|
||
with CSV_PATH.open("w", newline="", encoding="utf-8") as f:
|
||
w = csv.writer(f)
|
||
w.writerow(["username", "country", "last_checked"])
|
||
for user, (country, last_checked) in sorted(cache.items()):
|
||
w.writerow([user, country or "", last_checked])
|
||
|
||
|
||
def needs_lookup(entry, cutoff):
|
||
"""True if this entry must be (re)queried. entry is None if absent."""
|
||
if entry is None:
|
||
return True # new stargazer
|
||
country, last_checked = entry
|
||
if country:
|
||
return False # a known country never changes here
|
||
if not last_checked:
|
||
return True # blank, never checked (pre-"last_checked" row)
|
||
return last_checked < cutoff # blank, and stale enough to retry
|
||
|
||
|
||
def username_to_country(login):
|
||
"""Return readable country name or '' if unknown."""
|
||
resp = requests.get(f"https://api.github.com/users/{login}", headers=HEADERS)
|
||
resp.raise_for_status()
|
||
loc = (resp.json() or {}).get("location") or ""
|
||
if not loc.strip():
|
||
return ""
|
||
if loc.strip().strip(".!").lower() in JUNK_LOCATIONS:
|
||
return ""
|
||
try:
|
||
g = GEOL.geocode(loc, language="en", addressdetails=True, timeout=10)
|
||
except Exception:
|
||
return ""
|
||
# Use the ISO code from the structured address: Nominatim's English display
|
||
# names ("Russia", "Turkey", "Ivory Coast") do not all match pycountry's ISO
|
||
# names ("Russian Federation", "Türkiye", "Côte d'Ivoire").
|
||
code = ((g.raw.get("address") or {}).get("country_code") or "") if g else ""
|
||
country = pycountry.countries.get(alpha_2=code.upper()) if code else None
|
||
return country.name if country else ""
|
||
|
||
|
||
def count_by_country(cache):
|
||
"""Counter of country name -> stargazers, ignoring blank locations."""
|
||
return Counter(country for country, _ in cache.values() if country)
|
||
|
||
|
||
def _log_ticks(lo, hi):
|
||
"""Colourbar ticks at ... 0.1, 0.3, 1, 3, 10, 30 ... spanning [lo, hi]."""
|
||
candidates = [m * 10**k for k in range(-3, 3) for m in (1, 3)]
|
||
ticks = [t for t in candidates if lo / 1.5 <= t <= hi]
|
||
return ticks or [hi]
|
||
|
||
|
||
def _fmt_pct(value):
|
||
"""1 -> '1%', 0.3 -> '0.3%' -- no trailing zeros."""
|
||
return f"{value:.2f}".rstrip("0").rstrip(".") + "%"
|
||
|
||
|
||
def build_figure(counts, total_stargazers):
|
||
"""Build the choropleth figure from a {country name: stargazers} mapping."""
|
||
by_iso = {}
|
||
for name, n in counts.items():
|
||
try:
|
||
code = pycountry.countries.lookup(name).alpha_3
|
||
except LookupError:
|
||
print("Skip unknown country:", name)
|
||
continue
|
||
# two spellings can resolve to the same ISO code, so accumulate
|
||
by_iso[code] = by_iso.get(code, 0) + n
|
||
|
||
iso = list(by_iso)
|
||
vals = [by_iso[k] for k in iso]
|
||
# count only what is actually drawn, so the caption matches the map
|
||
located = sum(vals) or 1
|
||
pcts = [v / located * 100 for v in vals]
|
||
lo, hi = (min(pcts), max(pcts)) if pcts else (1.0, 1.0)
|
||
|
||
# The distribution is heavily long-tailed (the top country holds ~200x the
|
||
# share of the tail), so a linear ramp collapses everything but a handful
|
||
# of countries into the first colour step. Colour on log10 of the share.
|
||
ticks = _log_ticks(lo, hi)
|
||
fig = go.Figure(
|
||
go.Choropleth(
|
||
locations=iso,
|
||
locationmode="ISO-3",
|
||
z=[math.log10(p) for p in pcts],
|
||
zmin=math.log10(lo) - 0.15, # keep the smallest share off the floor
|
||
zmax=math.log10(hi),
|
||
colorscale=SCALE,
|
||
marker_line_color=BORDER,
|
||
marker_line_width=0.5,
|
||
colorbar=dict(
|
||
title=dict(
|
||
text="share of located stargazers (log scale)",
|
||
font=dict(color=MUTED, size=13),
|
||
side="top",
|
||
),
|
||
orientation="h",
|
||
x=0.52,
|
||
y=0.02,
|
||
xanchor="center",
|
||
yanchor="bottom",
|
||
thickness=12,
|
||
len=0.34,
|
||
outlinewidth=0,
|
||
tickvals=[math.log10(t) for t in ticks],
|
||
ticktext=[_fmt_pct(t) for t in ticks],
|
||
tickfont=dict(color=MUTED, size=12),
|
||
),
|
||
)
|
||
)
|
||
|
||
fig.update_geos(
|
||
projection_type="natural earth",
|
||
showframe=False,
|
||
showcoastlines=False,
|
||
showland=True,
|
||
landcolor=LAND,
|
||
showocean=True,
|
||
oceancolor=BG,
|
||
showlakes=False,
|
||
bgcolor=BG,
|
||
lataxis_range=[-56, 84], # crop Antarctica, it is always empty
|
||
lonaxis_range=[-176, 186],
|
||
domain=dict(x=[0.0, 1.0], y=[0.04, 0.92]),
|
||
)
|
||
|
||
repo = REPO or "this repository"
|
||
caption = (
|
||
f"{total_stargazers:,} stargazers"
|
||
f" | {located:,} mapped to a country"
|
||
f" | {len(by_iso)} countries"
|
||
)
|
||
annotations = [
|
||
dict(
|
||
text=f"<b>Stargazers of {repo}</b>",
|
||
x=0.012,
|
||
y=0.985,
|
||
xref="paper",
|
||
yref="paper",
|
||
xanchor="left",
|
||
yanchor="top",
|
||
showarrow=False,
|
||
font=dict(color=FG, size=25),
|
||
),
|
||
dict(
|
||
text=caption,
|
||
x=0.012,
|
||
y=0.925,
|
||
xref="paper",
|
||
yref="paper",
|
||
xanchor="left",
|
||
yanchor="top",
|
||
showarrow=False,
|
||
font=dict(color=MUTED, size=15),
|
||
),
|
||
dict(
|
||
text="Countries in grey have no located stargazer.<br>"
|
||
"Location is read from the public GitHub profile,<br>"
|
||
"so the map covers the located subset only.<br>"
|
||
"Country lookup by Nominatim geocoding,<br>"
|
||
"data © OpenStreetMap contributors.",
|
||
x=0.988,
|
||
y=0.05,
|
||
xref="paper",
|
||
yref="paper",
|
||
xanchor="right",
|
||
yanchor="bottom",
|
||
align="right",
|
||
showarrow=False,
|
||
font=dict(color=MUTED, size=12),
|
||
),
|
||
]
|
||
|
||
# Top 5, laid out as two separate annotations (names, share) so each column
|
||
# stays aligned whatever the country name length -- HTML text in an SVG
|
||
# annotation collapses padding spaces, so a monospace table would not line
|
||
# up.
|
||
top = counts.most_common(5)
|
||
if top:
|
||
base_y = 0.40
|
||
columns = [
|
||
(
|
||
0.022,
|
||
"left",
|
||
"<br>".join(
|
||
f"{i}. {SHORT_NAMES.get(name, name)}"
|
||
for i, (name, _) in enumerate(top, 1)
|
||
),
|
||
FG,
|
||
),
|
||
(
|
||
0.215,
|
||
"right",
|
||
"<br>".join(f"{n / located * 100:.1f}%" for _, n in top),
|
||
FG,
|
||
),
|
||
]
|
||
annotations.append(
|
||
dict(
|
||
text="<b>TOP COUNTRIES</b>",
|
||
x=0.022,
|
||
y=base_y,
|
||
xref="paper",
|
||
yref="paper",
|
||
xanchor="left",
|
||
yanchor="top",
|
||
showarrow=False,
|
||
font=dict(color=MUTED, size=13),
|
||
)
|
||
)
|
||
annotations += [
|
||
dict(
|
||
text=text,
|
||
x=x,
|
||
y=base_y - 0.055,
|
||
xref="paper",
|
||
yref="paper",
|
||
xanchor=anchor,
|
||
yanchor="top",
|
||
align=anchor,
|
||
showarrow=False,
|
||
font=dict(color=color, size=15),
|
||
)
|
||
for x, anchor, text, color in columns
|
||
]
|
||
|
||
fig.update_layout(
|
||
width=1240,
|
||
height=680,
|
||
paper_bgcolor=BG,
|
||
plot_bgcolor=BG,
|
||
margin=dict(l=0, r=0, t=0, b=0),
|
||
annotations=annotations,
|
||
)
|
||
return fig
|
||
|
||
|
||
def build_choropleth(counts, total_stargazers, path=PNG_PATH):
|
||
fig = build_figure(counts, total_stargazers)
|
||
path.parent.mkdir(parents=True, exist_ok=True)
|
||
# 1.5x of 1240x680 -> 1860x1020, sharp on HiDPI at README width without
|
||
# committing a multi-megabyte PNG every week.
|
||
fig.write_image(str(path), scale=1.5)
|
||
|
||
|
||
def main():
|
||
if not REPO or not GITHUB_TOKEN:
|
||
sys.exit("REPO and GITHUB_TOKEN env vars are required")
|
||
|
||
print("Fetching stargazer list…")
|
||
users = fetch_stargazer_usernames()
|
||
print(f"Total stargazers: {len(users)}")
|
||
|
||
cache = load_cache()
|
||
|
||
# Determine which usernames need a lookup. Anything never checked -- a new
|
||
# stargazer, or a row migrated from the pre-"last_checked" CSV -- is looked
|
||
# up in full. Rows that were checked before and have since expired are
|
||
# rate-limited to MAX_RECHECKS_PER_RUN, oldest first, so the recurring
|
||
# RECHECK_DAYS wave arrives in slices rather than all at once.
|
||
now = datetime.date.today()
|
||
today = now.isoformat()
|
||
cutoff = (now - datetime.timedelta(days=RECHECK_DAYS)).isoformat()
|
||
due = [u for u in users if needs_lookup(cache.get(u), cutoff)]
|
||
never = [u for u in due if not cache.get(u, ("", ""))[1]]
|
||
expired = sorted(
|
||
(u for u in due if cache.get(u, ("", ""))[1]),
|
||
key=lambda u: (cache[u][1], u),
|
||
)
|
||
rechecks = expired[:MAX_RECHECKS_PER_RUN]
|
||
to_lookup = never + rechecks
|
||
print(
|
||
f"Need geocode for {len(to_lookup)} users "
|
||
f"({len(never)} never checked, {len(rechecks)} of {len(expired)} expired)"
|
||
)
|
||
|
||
for i, login in enumerate(to_lookup, 1):
|
||
country = username_to_country(login)
|
||
cache[login] = (country, today)
|
||
print(f"{i}/{len(to_lookup)}: {login:<20} -> {country}")
|
||
# Nominatim polite usage
|
||
time.sleep(1)
|
||
|
||
# Ensure all stargazers are in cache (even those with blank location)
|
||
for u in users:
|
||
cache.setdefault(u, ("", today))
|
||
|
||
save_cache(cache)
|
||
|
||
# The cache is never pruned, so it still holds users who have since
|
||
# unstarred. Keep them for future geocoding, but render only current stars.
|
||
counts = count_by_country({u: cache[u] for u in users})
|
||
|
||
print("Rendering PNG map…")
|
||
build_choropleth(counts, len(users))
|
||
print(
|
||
"Done – files saved:",
|
||
CSV_PATH.relative_to("."),
|
||
PNG_PATH.relative_to("."),
|
||
sep="\n• ",
|
||
)
|
||
|
||
|
||
if __name__ == "__main__":
|
||
main()
|