Files
hassio-addons/.github/generate_map.py
Alexandre e219d241bc perf(stargazer-map): negative-cache blank locations for 90 days (#2954)
* 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>
2026-08-10 16:24:38 +02:00

465 lines
15 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/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()