#!/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"Stargazers of {repo}",
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.
"
"Location is read from the public GitHub profile,
"
"so the map covers the located subset only.
"
"Country lookup by Nominatim geocoding,
"
"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",
"
".join(
f"{i}. {SHORT_NAMES.get(name, name)}"
for i, (name, _) in enumerate(top, 1)
),
FG,
),
(
0.215,
"right",
"
".join(f"{n / located * 100:.1f}%" for _, n in top),
FG,
),
]
annotations.append(
dict(
text="TOP COUNTRIES",
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()