for assorted things
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()