Last active
November 1, 2025 15:01
-
-
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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/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