#!/usr/bin/env -S uv run --script --quiet # /// script # requires-python = ">=3.12" # dependencies = ["httpx", "rich", "pydantic-settings"] # /// """ analyze your github followers and following. usage: ./analyze-github-followers ./analyze-github-followers --summary-only # skip detailed analysis details: - uses github rest api to fetch followers/following - shows rich tables with follower stats - identifies mutual follows, notable followers, etc. - requires GITHUB_TOKEN in .env file """ from __future__ import annotations import argparse import os from datetime import datetime, timezone from typing import NamedTuple import httpx from pydantic import Field from pydantic_settings import BaseSettings, SettingsConfigDict from rich.console import Console from rich.panel import Panel from rich.progress import Progress, SpinnerColumn, TextColumn from rich.table import Table console = Console() class Settings(BaseSettings): """load settings from environment""" model_config = SettingsConfigDict( env_file=os.environ.get("ENV_FILE", ".env"), extra="ignore" ) github_token: str = Field(description="github api token") class GitHubUser(NamedTuple): """github user information""" login: str name: str | None bio: str | None followers: int following: int public_repos: int created_at: datetime url: str company: str | None location: str | None blog: str | None @property def follower_ratio(self) -> float: """ratio of followers to following (higher = more influential)""" if self.following == 0: return float(self.followers) if self.followers > 0 else 0.0 return self.followers / self.following def _headers(token: str) -> dict[str, str]: return { "Accept": "application/vnd.github.v3+json", "Authorization": f"token {token}", } def get_authenticated_user(token: str) -> str: """get the authenticated user's login""" with httpx.Client() as client: r = client.get("https://api.github.com/user", headers=_headers(token)) r.raise_for_status() return r.json()["login"] def get_user_details(username: str, token: str) -> GitHubUser: """fetch detailed user information""" with httpx.Client() as client: r = client.get( f"https://api.github.com/users/{username}", headers=_headers(token) ) r.raise_for_status() data = r.json() return GitHubUser( login=data["login"], name=data.get("name"), bio=data.get("bio"), followers=data["followers"], following=data["following"], public_repos=data["public_repos"], created_at=datetime.fromisoformat(data["created_at"].replace("Z", "+00:00")), url=data["html_url"], company=data.get("company"), location=data.get("location"), blog=data.get("blog"), ) def get_all_followers(username: str, token: str) -> list[str]: """fetch all followers (just logins for now)""" followers = [] page = 1 with httpx.Client() as client: while True: r = client.get( f"https://api.github.com/users/{username}/followers?page={page}&per_page=100", headers=_headers(token), ) r.raise_for_status() data = r.json() if not data: break followers.extend([user["login"] for user in data]) page += 1 return followers def get_all_following(username: str, token: str) -> list[str]: """fetch all users being followed""" following = [] page = 1 with httpx.Client() as client: while True: r = client.get( f"https://api.github.com/users/{username}/following?page={page}&per_page=100", headers=_headers(token), ) r.raise_for_status() data = r.json() if not data: break following.extend([user["login"] for user in data]) page += 1 return following def main(): """main function to analyze github followers""" parser = argparse.ArgumentParser(description="analyze your github followers") parser.add_argument( "--summary-only", action="store_true", help="show summary only, skip detailed follower analysis", ) args = parser.parse_args() try: settings = Settings() # type: ignore except Exception as e: console.print(f"[red]error loading settings: {e}[/red]") console.print("[dim]ensure .env file exists with GITHUB_TOKEN[/dim]") return token = settings.github_token.strip() try: # get authenticated user username = get_authenticated_user(token) console.print(f"[blue]analyzing followers for @{username}[/blue]\n") # fetch user details with Progress( SpinnerColumn(), TextColumn("[progress.description]{task.description}"), console=console, ) as progress: task = progress.add_task("fetching your profile...", total=None) user = get_user_details(username, token) progress.update(task, completed=True) # show profile info profile_text = f"[bold cyan]@{user.login}[/bold cyan]" if user.name: profile_text += f" ({user.name})" profile_text += f"\n[dim]joined {user.created_at:%Y-%m-%d}[/dim]" if user.bio: profile_text += f"\n{user.bio}" if user.location: profile_text += f"\nšŸ“ {user.location}" if user.company: profile_text += f"\nšŸ¢ {user.company}" console.print(Panel.fit(profile_text, border_style="blue")) console.print() # fetch followers and following with Progress( SpinnerColumn(), TextColumn("[progress.description]{task.description}"), console=console, ) as progress: task1 = progress.add_task("fetching followers...", total=None) followers = get_all_followers(username, token) progress.update(task1, completed=True) task2 = progress.add_task("fetching following...", total=None) following = get_all_following(username, token) progress.update(task2, completed=True) # analyze relationships followers_set = set(followers) following_set = set(following) mutual = followers_set & following_set followers_only = followers_set - following_set following_only = following_set - followers_set # summary table summary_table = Table(show_header=True, header_style="bold magenta") summary_table.add_column("metric", style="cyan") summary_table.add_column("count", justify="right", style="white") summary_table.add_row("total followers", str(len(followers))) summary_table.add_row("total following", str(len(following))) summary_table.add_row("mutual follows", f"[green]{len(mutual)}[/green]") summary_table.add_row( "followers not following back", f"[yellow]{len(followers_only)}[/yellow]" ) summary_table.add_row( "following but not following back", f"[red]{len(following_only)}[/red]" ) summary_table.add_row("public repos", str(user.public_repos)) console.print(summary_table) console.print() # fetch details for all followers if followers and not args.summary_only: console.print( f"\n[bold yellow]analyzing all {len(followers)} followers...[/bold yellow]" ) follower_details = [] with Progress( SpinnerColumn(), TextColumn("[progress.description]{task.description}"), console=console, ) as progress: task = progress.add_task( f"fetching follower details...", total=len(followers) ) for follower_login in followers: try: details = get_user_details(follower_login, token) follower_details.append(details) except Exception: pass # skip if we can't fetch details progress.advance(task) if follower_details: # most influential followers by follower ratio # filter out accounts with very few followers to avoid noise influential = [f for f in follower_details if f.followers >= 100] influential = sorted(influential, key=lambda u: u.follower_ratio, reverse=True)[:10] console.print() console.print("[bold magenta]most influential followers:[/bold magenta]") console.print("[dim]ranked by followers-to-following ratio[/dim]\n") followers_table = Table(show_header=True, header_style="bold magenta") followers_table.add_column("username", style="cyan") followers_table.add_column("name", style="white") followers_table.add_column("followers", justify="right", style="blue") followers_table.add_column("following", justify="right", style="yellow") followers_table.add_column("ratio", justify="right", style="green") followers_table.add_column("mutual", justify="center", style="magenta") for follower in influential: is_mutual = "āœ“" if follower.login in mutual else "" followers_table.add_row( f"@{follower.login}", follower.name or "[dim]no name[/dim]", f"{follower.followers:,}", f"{follower.following:,}", f"{follower.follower_ratio:.1f}x", is_mutual, ) console.print(followers_table) # location analysis locations = [ f.location for f in follower_details if f.location ] if locations: from collections import Counter location_counts = Counter(locations).most_common(5) console.print("\n[bold magenta]top follower locations:[/bold magenta]") location_table = Table(show_header=False) location_table.add_column("location", style="cyan") location_table.add_column("count", justify="right", style="white") for loc, count in location_counts: location_table.add_row(loc, str(count)) console.print(location_table) # company analysis companies = [ f.company.lstrip("@").strip() for f in follower_details if f.company ] if companies: from collections import Counter company_counts = Counter(companies).most_common(5) console.print("\n[bold magenta]top follower companies:[/bold magenta]") company_table = Table(show_header=False) company_table.add_column("company", style="cyan") company_table.add_column("count", justify="right", style="white") for comp, count in company_counts: company_table.add_row(comp, str(count)) console.print(company_table) # account age analysis now = datetime.now(timezone.utc) ages_years = [(now - f.created_at).days / 365.25 for f in follower_details] avg_age = sum(ages_years) / len(ages_years) oldest = min(follower_details, key=lambda f: f.created_at) newest = max(follower_details, key=lambda f: f.created_at) console.print("\n[bold magenta]account age stats:[/bold magenta]") age_table = Table(show_header=False) age_table.add_column("metric", style="cyan") age_table.add_column("value", style="white") age_table.add_row("average follower account age", f"{avg_age:.1f} years") age_table.add_row("oldest follower account", f"@{oldest.login} ({oldest.created_at:%Y-%m-%d})") age_table.add_row("newest follower account", f"@{newest.login} ({newest.created_at:%Y-%m-%d})") console.print(age_table) # repo stats repo_counts = [f.public_repos for f in follower_details] avg_repos = sum(repo_counts) / len(repo_counts) most_repos = max(follower_details, key=lambda f: f.public_repos) console.print("\n[bold magenta]repository stats:[/bold magenta]") repo_table = Table(show_header=False) repo_table.add_column("metric", style="cyan") repo_table.add_column("value", style="white") repo_table.add_row("average repos per follower", f"{avg_repos:.1f}") repo_table.add_row("follower with most repos", f"@{most_repos.login} ({most_repos.public_repos:,} repos)") repo_table.add_row("followers with 0 repos", str(sum(1 for f in follower_details if f.public_repos == 0))) console.print(repo_table) except httpx.HTTPStatusError as e: if e.response.status_code == 401: console.print("[red]error: invalid github token[/red]") elif e.response.status_code == 403: console.print("[red]error: rate limit exceeded[/red]") else: console.print(f"[red]github api error: {e.response.status_code}[/red]") except Exception as e: console.print(f"[red]error: {e}[/red]") if __name__ == "__main__": main()