for assorted things
at main 14 kB view raw
1#!/usr/bin/env -S uv run --script --quiet 2# /// script 3# requires-python = ">=3.12" 4# dependencies = ["httpx", "rich", "pydantic-settings"] 5# /// 6""" 7analyze your github followers and following. 8 9usage: 10 ./analyze-github-followers 11 ./analyze-github-followers --summary-only # skip detailed analysis 12 13details: 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 20from __future__ import annotations 21 22import argparse 23import os 24from datetime import datetime, timezone 25from typing import NamedTuple 26 27import httpx 28from pydantic import Field 29from pydantic_settings import BaseSettings, SettingsConfigDict 30from rich.console import Console 31from rich.panel import Panel 32from rich.progress import Progress, SpinnerColumn, TextColumn 33from rich.table import Table 34 35console = Console() 36 37 38class 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 47class 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 70def _headers(token: str) -> dict[str, str]: 71 return { 72 "Accept": "application/vnd.github.v3+json", 73 "Authorization": f"token {token}", 74 } 75 76 77def 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 85def 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 108def 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 127def 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 146def 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 365if __name__ == "__main__": 366 main()