for assorted things

add analyze-github-followers script

analyzes github followers with rich insights:
- follower/following relationship stats
- most influential followers by follower-to-following ratio
- geographic and company distribution
- account age and repository statistics
- optional --summary-only flag for quick overview

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

+17
README.md
··· 10 10 11 11 ## scripts 12 12 13 + - [`analyze-github-followers`](#analyze-github-followers) 13 14 - [`check-files-for-bad-links`](#check-files-for-bad-links) 14 15 - [`dm-me-when-a-flight-passes-over`](#dm-me-when-a-flight-passes-over) 15 16 - [`find-longest-bsky-thread`](#find-longest-bsky-thread) ··· 18 19 - [`predict-github-stars`](#predict-github-stars) 19 20 - [`update-lights`](#update-lights) 20 21 - [`update-readme`](#update-readme) 22 + 23 + --- 24 + 25 + ### `analyze-github-followers` 26 + 27 + analyze your github followers and following. 28 + 29 + usage: 30 + ./analyze-github-followers 31 + ./analyze-github-followers --summary-only # skip detailed analysis 32 + 33 + details: 34 + - uses github rest api to fetch followers/following 35 + - shows rich tables with follower stats 36 + - identifies mutual follows, notable followers, etc. 37 + - requires GITHUB_TOKEN in .env file 21 38 22 39 --- 23 40
+366
analyze-github-followers
··· 1 + #!/usr/bin/env -S uv run --script --quiet 2 + # /// script 3 + # requires-python = ">=3.12" 4 + # dependencies = ["httpx", "rich", "pydantic-settings"] 5 + # /// 6 + """ 7 + analyze your github followers and following. 8 + 9 + usage: 10 + ./analyze-github-followers 11 + ./analyze-github-followers --summary-only # skip detailed analysis 12 + 13 + details: 14 + - uses github rest api to fetch followers/following 15 + - shows rich tables with follower stats 16 + - identifies mutual follows, notable followers, etc. 17 + - requires GITHUB_TOKEN in .env file 18 + """ 19 + 20 + from __future__ import annotations 21 + 22 + import argparse 23 + import os 24 + from datetime import datetime, timezone 25 + from typing import NamedTuple 26 + 27 + import httpx 28 + from pydantic import Field 29 + from pydantic_settings import BaseSettings, SettingsConfigDict 30 + from rich.console import Console 31 + from rich.panel import Panel 32 + from rich.progress import Progress, SpinnerColumn, TextColumn 33 + from rich.table import Table 34 + 35 + console = Console() 36 + 37 + 38 + class Settings(BaseSettings): 39 + """load settings from environment""" 40 + 41 + model_config = SettingsConfigDict( 42 + env_file=os.environ.get("ENV_FILE", ".env"), extra="ignore" 43 + ) 44 + github_token: str = Field(description="github api token") 45 + 46 + 47 + class GitHubUser(NamedTuple): 48 + """github user information""" 49 + 50 + login: str 51 + name: str | None 52 + bio: str | None 53 + followers: int 54 + following: int 55 + public_repos: int 56 + created_at: datetime 57 + url: str 58 + company: str | None 59 + location: str | None 60 + blog: str | None 61 + 62 + @property 63 + def follower_ratio(self) -> float: 64 + """ratio of followers to following (higher = more influential)""" 65 + if self.following == 0: 66 + return float(self.followers) if self.followers > 0 else 0.0 67 + return self.followers / self.following 68 + 69 + 70 + def _headers(token: str) -> dict[str, str]: 71 + return { 72 + "Accept": "application/vnd.github.v3+json", 73 + "Authorization": f"token {token}", 74 + } 75 + 76 + 77 + def get_authenticated_user(token: str) -> str: 78 + """get the authenticated user's login""" 79 + with httpx.Client() as client: 80 + r = client.get("https://api.github.com/user", headers=_headers(token)) 81 + r.raise_for_status() 82 + return r.json()["login"] 83 + 84 + 85 + def get_user_details(username: str, token: str) -> GitHubUser: 86 + """fetch detailed user information""" 87 + with httpx.Client() as client: 88 + r = client.get( 89 + f"https://api.github.com/users/{username}", headers=_headers(token) 90 + ) 91 + r.raise_for_status() 92 + data = r.json() 93 + return GitHubUser( 94 + login=data["login"], 95 + name=data.get("name"), 96 + bio=data.get("bio"), 97 + followers=data["followers"], 98 + following=data["following"], 99 + public_repos=data["public_repos"], 100 + created_at=datetime.fromisoformat(data["created_at"].replace("Z", "+00:00")), 101 + url=data["html_url"], 102 + company=data.get("company"), 103 + location=data.get("location"), 104 + blog=data.get("blog"), 105 + ) 106 + 107 + 108 + def get_all_followers(username: str, token: str) -> list[str]: 109 + """fetch all followers (just logins for now)""" 110 + followers = [] 111 + page = 1 112 + with httpx.Client() as client: 113 + while True: 114 + r = client.get( 115 + f"https://api.github.com/users/{username}/followers?page={page}&per_page=100", 116 + headers=_headers(token), 117 + ) 118 + r.raise_for_status() 119 + data = r.json() 120 + if not data: 121 + break 122 + followers.extend([user["login"] for user in data]) 123 + page += 1 124 + return followers 125 + 126 + 127 + def get_all_following(username: str, token: str) -> list[str]: 128 + """fetch all users being followed""" 129 + following = [] 130 + page = 1 131 + with httpx.Client() as client: 132 + while True: 133 + r = client.get( 134 + f"https://api.github.com/users/{username}/following?page={page}&per_page=100", 135 + headers=_headers(token), 136 + ) 137 + r.raise_for_status() 138 + data = r.json() 139 + if not data: 140 + break 141 + following.extend([user["login"] for user in data]) 142 + page += 1 143 + return following 144 + 145 + 146 + def main(): 147 + """main function to analyze github followers""" 148 + parser = argparse.ArgumentParser(description="analyze your github followers") 149 + parser.add_argument( 150 + "--summary-only", 151 + action="store_true", 152 + help="show summary only, skip detailed follower analysis", 153 + ) 154 + args = parser.parse_args() 155 + 156 + try: 157 + settings = Settings() # type: ignore 158 + except Exception as e: 159 + console.print(f"[red]error loading settings: {e}[/red]") 160 + console.print("[dim]ensure .env file exists with GITHUB_TOKEN[/dim]") 161 + return 162 + 163 + token = settings.github_token.strip() 164 + 165 + try: 166 + # get authenticated user 167 + username = get_authenticated_user(token) 168 + console.print(f"[blue]analyzing followers for @{username}[/blue]\n") 169 + 170 + # fetch user details 171 + with Progress( 172 + SpinnerColumn(), 173 + TextColumn("[progress.description]{task.description}"), 174 + console=console, 175 + ) as progress: 176 + task = progress.add_task("fetching your profile...", total=None) 177 + user = get_user_details(username, token) 178 + progress.update(task, completed=True) 179 + 180 + # show profile info 181 + profile_text = f"[bold cyan]@{user.login}[/bold cyan]" 182 + if user.name: 183 + profile_text += f" ({user.name})" 184 + profile_text += f"\n[dim]joined {user.created_at:%Y-%m-%d}[/dim]" 185 + if user.bio: 186 + profile_text += f"\n{user.bio}" 187 + if user.location: 188 + profile_text += f"\n📍 {user.location}" 189 + if user.company: 190 + profile_text += f"\n🏢 {user.company}" 191 + 192 + console.print(Panel.fit(profile_text, border_style="blue")) 193 + console.print() 194 + 195 + # fetch followers and following 196 + with Progress( 197 + SpinnerColumn(), 198 + TextColumn("[progress.description]{task.description}"), 199 + console=console, 200 + ) as progress: 201 + task1 = progress.add_task("fetching followers...", total=None) 202 + followers = get_all_followers(username, token) 203 + progress.update(task1, completed=True) 204 + 205 + task2 = progress.add_task("fetching following...", total=None) 206 + following = get_all_following(username, token) 207 + progress.update(task2, completed=True) 208 + 209 + # analyze relationships 210 + followers_set = set(followers) 211 + following_set = set(following) 212 + 213 + mutual = followers_set & following_set 214 + followers_only = followers_set - following_set 215 + following_only = following_set - followers_set 216 + 217 + # summary table 218 + summary_table = Table(show_header=True, header_style="bold magenta") 219 + summary_table.add_column("metric", style="cyan") 220 + summary_table.add_column("count", justify="right", style="white") 221 + 222 + summary_table.add_row("total followers", str(len(followers))) 223 + summary_table.add_row("total following", str(len(following))) 224 + summary_table.add_row("mutual follows", f"[green]{len(mutual)}[/green]") 225 + summary_table.add_row( 226 + "followers not following back", f"[yellow]{len(followers_only)}[/yellow]" 227 + ) 228 + summary_table.add_row( 229 + "following but not following back", f"[red]{len(following_only)}[/red]" 230 + ) 231 + summary_table.add_row("public repos", str(user.public_repos)) 232 + 233 + console.print(summary_table) 234 + console.print() 235 + 236 + # fetch details for all followers 237 + if followers and not args.summary_only: 238 + console.print( 239 + f"\n[bold yellow]analyzing all {len(followers)} followers...[/bold yellow]" 240 + ) 241 + follower_details = [] 242 + 243 + with Progress( 244 + SpinnerColumn(), 245 + TextColumn("[progress.description]{task.description}"), 246 + console=console, 247 + ) as progress: 248 + task = progress.add_task( 249 + f"fetching follower details...", total=len(followers) 250 + ) 251 + 252 + for follower_login in followers: 253 + try: 254 + details = get_user_details(follower_login, token) 255 + follower_details.append(details) 256 + except Exception: 257 + pass # skip if we can't fetch details 258 + progress.advance(task) 259 + 260 + if follower_details: 261 + # most influential followers by follower ratio 262 + # filter out accounts with very few followers to avoid noise 263 + influential = [f for f in follower_details if f.followers >= 100] 264 + influential = sorted(influential, key=lambda u: u.follower_ratio, reverse=True)[:10] 265 + 266 + console.print() 267 + console.print("[bold magenta]most influential followers:[/bold magenta]") 268 + console.print("[dim]ranked by followers-to-following ratio[/dim]\n") 269 + followers_table = Table(show_header=True, header_style="bold magenta") 270 + followers_table.add_column("username", style="cyan") 271 + followers_table.add_column("name", style="white") 272 + followers_table.add_column("followers", justify="right", style="blue") 273 + followers_table.add_column("following", justify="right", style="yellow") 274 + followers_table.add_column("ratio", justify="right", style="green") 275 + followers_table.add_column("mutual", justify="center", style="magenta") 276 + 277 + for follower in influential: 278 + is_mutual = "✓" if follower.login in mutual else "" 279 + followers_table.add_row( 280 + f"@{follower.login}", 281 + follower.name or "[dim]no name[/dim]", 282 + f"{follower.followers:,}", 283 + f"{follower.following:,}", 284 + f"{follower.follower_ratio:.1f}x", 285 + is_mutual, 286 + ) 287 + 288 + console.print(followers_table) 289 + 290 + # location analysis 291 + locations = [ 292 + f.location for f in follower_details if f.location 293 + ] 294 + if locations: 295 + from collections import Counter 296 + 297 + location_counts = Counter(locations).most_common(5) 298 + console.print("\n[bold magenta]top follower locations:[/bold magenta]") 299 + location_table = Table(show_header=False) 300 + location_table.add_column("location", style="cyan") 301 + location_table.add_column("count", justify="right", style="white") 302 + for loc, count in location_counts: 303 + location_table.add_row(loc, str(count)) 304 + console.print(location_table) 305 + 306 + # company analysis 307 + companies = [ 308 + f.company.lstrip("@").strip() 309 + for f in follower_details 310 + if f.company 311 + ] 312 + if companies: 313 + from collections import Counter 314 + 315 + company_counts = Counter(companies).most_common(5) 316 + console.print("\n[bold magenta]top follower companies:[/bold magenta]") 317 + company_table = Table(show_header=False) 318 + company_table.add_column("company", style="cyan") 319 + company_table.add_column("count", justify="right", style="white") 320 + for comp, count in company_counts: 321 + company_table.add_row(comp, str(count)) 322 + console.print(company_table) 323 + 324 + # account age analysis 325 + now = datetime.now(timezone.utc) 326 + ages_years = [(now - f.created_at).days / 365.25 for f in follower_details] 327 + avg_age = sum(ages_years) / len(ages_years) 328 + oldest = min(follower_details, key=lambda f: f.created_at) 329 + newest = max(follower_details, key=lambda f: f.created_at) 330 + 331 + console.print("\n[bold magenta]account age stats:[/bold magenta]") 332 + age_table = Table(show_header=False) 333 + age_table.add_column("metric", style="cyan") 334 + age_table.add_column("value", style="white") 335 + age_table.add_row("average follower account age", f"{avg_age:.1f} years") 336 + age_table.add_row("oldest follower account", f"@{oldest.login} ({oldest.created_at:%Y-%m-%d})") 337 + age_table.add_row("newest follower account", f"@{newest.login} ({newest.created_at:%Y-%m-%d})") 338 + console.print(age_table) 339 + 340 + # repo stats 341 + repo_counts = [f.public_repos for f in follower_details] 342 + avg_repos = sum(repo_counts) / len(repo_counts) 343 + most_repos = max(follower_details, key=lambda f: f.public_repos) 344 + 345 + console.print("\n[bold magenta]repository stats:[/bold magenta]") 346 + repo_table = Table(show_header=False) 347 + repo_table.add_column("metric", style="cyan") 348 + repo_table.add_column("value", style="white") 349 + repo_table.add_row("average repos per follower", f"{avg_repos:.1f}") 350 + repo_table.add_row("follower with most repos", f"@{most_repos.login} ({most_repos.public_repos:,} repos)") 351 + repo_table.add_row("followers with 0 repos", str(sum(1 for f in follower_details if f.public_repos == 0))) 352 + console.print(repo_table) 353 + 354 + except httpx.HTTPStatusError as e: 355 + if e.response.status_code == 401: 356 + console.print("[red]error: invalid github token[/red]") 357 + elif e.response.status_code == 403: 358 + console.print("[red]error: rate limit exceeded[/red]") 359 + else: 360 + console.print(f"[red]github api error: {e.response.status_code}[/red]") 361 + except Exception as e: 362 + console.print(f"[red]error: {e}[/red]") 363 + 364 + 365 + if __name__ == "__main__": 366 + main()