Skip to content

Instantly share code, notes, and snippets.

@JD-P
Last active November 1, 2025 15:01
Show Gist options
  • Select an option

  • Save JD-P/50c18eb60cfa452511e2890b4b2f2bed to your computer and use it in GitHub Desktop.

Select an option

Save JD-P/50c18eb60cfa452511e2890b4b2f2bed to your computer and use it in GitHub Desktop.
Computes a GitHub Contributions style visualization for a users public BlueSky posts using matplotlib
#!/usr/bin/env python3
import argparse, calendar, datetime as dt, json, math, time
from collections import defaultdict
from urllib.parse import urlencode
from urllib.request import urlopen, Request
import matplotlib
matplotlib.use("Agg") # headless
import matplotlib.pyplot as plt
from dateutil import tz
# --------- API helpers ---------
PUBLIC_API = "https://public.api.bsky.app/xrpc/app.bsky.feed.getAuthorFeed"
def _api_get(url, params, retry=4, backoff=0.8):
qs = urlencode(params)
req = Request(f"{url}?{qs}", headers={"User-Agent":"bsky-contrib-heatmap/1.0"})
for i in range(retry):
try:
with urlopen(req, timeout=20) as r:
return json.loads(r.read().decode("utf-8"))
except Exception as e:
# simple backoff on network hiccups or 429/5xx
if i == retry - 1: raise
time.sleep(backoff * (2 ** i))
def iter_posts(handle, since_utc):
"""
Yield post timestamps (UTC, datetime) for the given actor back until since_utc.
"""
cursor = None
while True:
params = {
"actor": handle,
"limit": 100,
"filter": "posts_with_replies", # was "posts_and_author_threads"
}
if cursor: params["cursor"] = cursor
data = _api_get(PUBLIC_API, params)
feed = data.get("feed", [])
if not feed:
break
# Each feed item typically has a "post" with "record" and "indexedAt"/"createdAt"
for item in feed:
# Skip pure reposts: author feed returns the original post when user reposts,
# marked by a "reason" field, so ignore those.
if "reason" in item: # app.bsky.feed.defs#reasonRepost
continue
post = item.get("post", {})
created = (post.get("record") or {}).get("createdAt") or post.get("indexedAt")
if not created:
continue
ts = dt.datetime.fromisoformat(created.replace("Z","+00:00")).astimezone(dt.timezone.utc)
if ts < since_utc:
return
yield ts
cursor = data.get("cursor")
if not cursor:
break
# --------- Heatmap palette / binning ---------
PALETTES = {
"green": ["#ebedf0","#9be9a8","#40c463","#30a14e","#216e39"],
"halloween": ["#161b22","#631c03","#bd561d","#fa7a18","#fddf68"], # dark->light oranges
"grayscale": ["#f0f0f0","#d9d9d9","#bdbdbd","#969696","#636363"],
}
def compute_levels(day_counts):
"""
Return a function mapping a count -> level in [0..4].
0 = 0 posts, 1..4 = increasing activity buckets (via quantiles).
"""
nonzero = sorted(c for c in day_counts.values() if c > 0)
if not nonzero:
edges = [1, 2, 3, 4]
else:
# 4 quantile edges
def q(p):
k = max(0, min(len(nonzero)-1, int(round(p*(len(nonzero)-1)))))
return nonzero[k]
edges = [q(p) for p in (0.20, 0.40, 0.60, 0.80)]
# enforce strictly increasing thresholds
for i in range(1, 4):
if edges[i] <= edges[i-1]:
edges[i] = edges[i-1] + 1
def level(c):
if c <= 0:
return 0
# 1..5 depending on how many edges it exceeds; then clamp to 4
lvl = 1 + sum(c > e for e in edges)
return 4 if lvl > 4 else lvl
return level
# --------- Grid building ---------
def daterange_days(end_utc, days):
# Build inclusive range [end-days+1, end] at UTC midnight
end_day = end_utc.date()
start_day = (end_day - dt.timedelta(days=days-1))
d = start_day
while d <= end_day:
yield d
d += dt.timedelta(days=1)
def to_local_day(ts_utc, tzinfo):
return ts_utc.astimezone(tzinfo).date()
def build_grid(day_counts, days, end_utc, tzinfo):
"""
Returns: grid (7 x W) of levels, month label positions
"""
# GitHub groups by week columns starting on Sunday
# We'll construct days then pivot into weeks
days_list = list(daterange_days(end_utc, days))
# ensure all days present
for d in days_list:
day_counts.setdefault(d, 0)
# Organize into columns by week starting Sunday
# Find the first displayed Sunday on/after the first day
first = days_list[0]
# move back to Sunday (weekday: Monday=0 ... Sunday=6) -> want Sunday=6 to 0 index
# Python: Monday=0; we want Sunday=6. Offset=(first.weekday()+1)%7
offset = (first.weekday() + 1) % 7
first_sun = first - dt.timedelta(days=offset)
# Total columns = weeks from first_sun to last day inclusive
last = days_list[-1]
n_weeks = math.ceil(((last - first_sun).days + 1) / 7)
# Fill a 7 x n_weeks matrix with zeros
grid = [[0 for _ in range(n_weeks)] for _ in range(7)]
# Level function
level_fn = compute_levels(day_counts)
# Fill levels
d = first_sun
for w in range(n_weeks):
for r in range(7):
if d in day_counts:
grid[r][w] = level_fn(day_counts[d])
else:
grid[r][w] = 0
d += dt.timedelta(days=1)
# Month labels: put label atop the first column that contains the first day of a month
month_labels = {}
d = first_sun
for w in range(n_weeks):
# If any day in this column is day=1 -> label
col_days = [d + dt.timedelta(days=r) for r in range(7)]
for cd in col_days:
if cd.day == 1:
month_labels[w] = calendar.month_abbr[cd.month]
break
d += dt.timedelta(days=7)
return grid, month_labels
# --------- Rendering ---------
def render_png(grid, month_labels, theme, title, outfile):
colors = PALETTES[theme]
rows = len(grid)
cols = len(grid[0]) if rows else 53
cell = 12
gap = 2
pad_left = 40
pad_right = 16
pad_bottom = 24
# Reserve more top space: ~60px gives us:
# ~0-20px = title
# ~28-40px = month labels
# >=60px = grid start
top_title_band = 20 # height for title
top_months_band = 20 # height for month labels
pad_top = top_title_band + top_months_band + 20 # gap before grid, e.g. 60px
w = pad_left + cols * (cell + gap) + pad_right
h = pad_top + rows * (cell + gap) + pad_bottom
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
fig = plt.figure(figsize=(w/96.0, h/96.0), dpi=96)
ax = plt.axes([0, 0, 1, 1])
ax.set_axis_off()
# --- draw cells (grid) ---
for r in range(rows):
for c in range(cols):
lvl = grid[r][c]
if lvl < 0: lvl = 0
if lvl >= len(colors): lvl = len(colors) - 1
ax.add_patch(plt.Rectangle(
(pad_left + c*(cell+gap), pad_top + r*(cell+gap)),
cell, cell, linewidth=0, facecolor=colors[lvl]
))
# --- weekday labels (Mon / Wed / Fri) aligned with grid rows ---
ylabels = {1: "Mon", 3: "Wed", 5: "Fri"}
for r, text in ylabels.items():
ax.text(
8,
pad_top + r*(cell+gap) + cell*0.8,
text,
fontsize=9,
color="#6e7781"
)
# --- month labels ---
# these go between title band and grid
# we'll put them at y = top_title_band + 16, which is safely below the title,
# and above the grid which starts at pad_top (~60px).
for c, m in month_labels.items():
ax.text(
pad_left + c*(cell+gap),
top_title_band + 16,
m,
fontsize=9,
color="#6e7781"
)
# --- title ---
if title:
# y close to 16 keeps it in the first ~20px band, well above months and grid.
ax.text(
pad_left,
4,
title,
va="top",
fontsize=11,
weight="bold",
color="#24292f"
)
ax.set_xlim(0, w)
ax.set_ylim(h, 0)
fig.savefig(outfile, dpi=96, bbox_inches="tight", pad_inches=0.15)
plt.close(fig)
# --------- Main ---------
def main():
ap = argparse.ArgumentParser(description="BlueSky -> GitHub-style contributions heatmap (PNG)")
ap.add_argument("handle", help="BlueSky handle, e.g. @alice.bsky.social or did:plc:...")
ap.add_argument("--out", default="bsky_contributions.png", help="output PNG path")
ap.add_argument("--days", type=int, default=365, help="how many days back (default 365)")
ap.add_argument("--tz", default="UTC", help="IANA timezone for day bucketing (default UTC)")
ap.add_argument("--theme", choices=list(PALETTES.keys()), default="green", help="color theme")
ap.add_argument("--title", default=None, help="title text (optional)")
args = ap.parse_args()
# time bounds
now_utc = dt.datetime.now(dt.timezone.utc)
since_utc = now_utc - dt.timedelta(days=args.days - 1)
# collect counts per local day
tzinfo = tz.gettz(args.tz)
counts = defaultdict(int)
# Remove @ from start of name
if args.handle.startswith("@"):
handle = args.handle[1:]
else:
handle = args.handle
for ts in iter_posts(handle, since_utc):
day_local = to_local_day(ts, tzinfo)
if day_local >= (now_utc.astimezone(tzinfo).date() - dt.timedelta(days=args.days-1)):
counts[day_local] += 1
grid, month_labels = build_grid(counts, args.days, now_utc, tzinfo)
title = args.title or f"{handle} — last {args.days} days"
render_png(grid, month_labels, args.theme, title, args.out)
print(f"Wrote {args.out}")
if __name__ == "__main__":
main()
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment