+17
README.md
+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
+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()