for assorted things

Compare changes

Choose any two refs to compare.

+37
README.md
··· 10 11 ## scripts 12 13 - [`check-files-for-bad-links`](#check-files-for-bad-links) 14 - [`dm-me-when-a-flight-passes-over`](#dm-me-when-a-flight-passes-over) 15 - [`find-longest-bsky-thread`](#find-longest-bsky-thread) 16 - [`kill-processes`](#kill-processes) 17 - [`predict-github-stars`](#predict-github-stars) 18 - [`update-lights`](#update-lights) 19 - [`update-readme`](#update-readme) 20 21 --- 22 ··· 169 Details: 170 - uses [`atproto`](https://github.com/MarshalX/atproto) to fetch the thread 171 - uses [`jinja2`](https://github.com/pallets/jinja) to render the thread 172 173 --- 174
··· 10 11 ## scripts 12 13 + - [`analyze-github-followers`](#analyze-github-followers) 14 - [`check-files-for-bad-links`](#check-files-for-bad-links) 15 - [`dm-me-when-a-flight-passes-over`](#dm-me-when-a-flight-passes-over) 16 - [`find-longest-bsky-thread`](#find-longest-bsky-thread) 17 + - [`find-stale-bsky-follows`](#find-stale-bsky-follows) 18 - [`kill-processes`](#kill-processes) 19 - [`predict-github-stars`](#predict-github-stars) 20 - [`update-lights`](#update-lights) 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 38 39 --- 40 ··· 187 Details: 188 - uses [`atproto`](https://github.com/MarshalX/atproto) to fetch the thread 189 - uses [`jinja2`](https://github.com/pallets/jinja) to render the thread 190 + 191 + --- 192 + 193 + ### `find-stale-bsky-follows` 194 + 195 + Find stale/inactive accounts among those you follow on Bluesky. 196 + 197 + Usage: 198 + 199 + ```bash 200 + ./find-stale-bsky-follows 201 + # or with custom inactivity threshold (days) 202 + ./find-stale-bsky-follows --days 180 203 + ``` 204 + 205 + Details: 206 + - uses [`atproto`](https://github.com/MarshalX/atproto) to fetch following list 207 + - uses [`rich`](https://github.com/Textualize/rich) for pretty output 208 + - identifies accounts with no recent posts 209 210 --- 211
+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()
+2 -1
find-longest-bsky-thread
··· 38 39 bsky_handle: str 40 bsky_password: str 41 42 43 def extract_post_uri(bluesky_url: str) -> str: ··· 171 ) 172 return 173 174 - client = Client() 175 try: 176 client.login(settings.bsky_handle, settings.bsky_password) 177 except Exception as e:
··· 38 39 bsky_handle: str 40 bsky_password: str 41 + bsky_pds_url: str = "https://bsky.social" 42 43 44 def extract_post_uri(bluesky_url: str) -> str: ··· 172 ) 173 return 174 175 + client = Client(base_url=settings.bsky_pds_url) 176 try: 177 client.login(settings.bsky_handle, settings.bsky_password) 178 except Exception as e:
+268
find-stale-bsky-follows
···
··· 1 + #!/usr/bin/env -S uv run --script --quiet 2 + # /// script 3 + # requires-python = ">=3.12" 4 + # dependencies = ["atproto", "pydantic-settings", "rich"] 5 + # /// 6 + """ 7 + Find stale/inactive accounts among those you follow on Bluesky. 8 + 9 + Usage: 10 + 11 + ```bash 12 + ./find-stale-bsky-follows 13 + # or with custom inactivity threshold (days) 14 + ./find-stale-bsky-follows --days 180 15 + ``` 16 + 17 + Details: 18 + - uses [`atproto`](https://github.com/MarshalX/atproto) to fetch following list 19 + - uses [`rich`](https://github.com/Textualize/rich) for pretty output 20 + - identifies accounts with no recent posts 21 + """ 22 + 23 + import argparse 24 + import os 25 + from datetime import datetime, timedelta, timezone 26 + from typing import NamedTuple 27 + 28 + from atproto import Client 29 + from pydantic_settings import BaseSettings, SettingsConfigDict 30 + from rich.console import Console 31 + from rich.progress import Progress, SpinnerColumn, TextColumn 32 + from rich.table import Table 33 + 34 + 35 + class Settings(BaseSettings): 36 + """App settings loaded from environment variables""" 37 + 38 + model_config = SettingsConfigDict( 39 + env_file=os.environ.get("ENV_FILE", ".env"), extra="ignore" 40 + ) 41 + 42 + bsky_handle: str 43 + bsky_password: str 44 + bsky_pds_url: str = "https://bsky.social" 45 + 46 + 47 + class AccountActivity(NamedTuple): 48 + """Activity information for a Bluesky account""" 49 + 50 + handle: str 51 + display_name: str | None 52 + did: str 53 + posts_count: int 54 + last_post_date: datetime | None 55 + days_inactive: int | None 56 + is_stale: bool 57 + 58 + 59 + def get_following_list(client: Client) -> list[dict]: 60 + """Fetch all accounts the authenticated user follows""" 61 + following = [] 62 + cursor = None 63 + 64 + while True: 65 + assert client.me, "client.me should be set" 66 + response = client.get_follows(client.me.did, cursor=cursor) 67 + following.extend(response.follows) 68 + 69 + if not response.cursor: 70 + break 71 + cursor = response.cursor 72 + 73 + return following 74 + 75 + 76 + def check_account_activity( 77 + client: Client, actor: dict, inactivity_threshold_days: int 78 + ) -> AccountActivity: 79 + """ 80 + Check the activity of a single account. 81 + 82 + Returns AccountActivity with stale status based on: 83 + - No posts at all 84 + - Last post older than threshold 85 + """ 86 + handle = actor.handle 87 + did = actor.did 88 + display_name = getattr(actor, "display_name", None) 89 + 90 + try: 91 + # Get the user's profile to check post count 92 + profile = client.get_profile(handle) 93 + posts_count = profile.posts_count or 0 94 + 95 + # If no posts, immediately mark as stale 96 + if posts_count == 0: 97 + return AccountActivity( 98 + handle=handle, 99 + display_name=display_name, 100 + did=did, 101 + posts_count=0, 102 + last_post_date=None, 103 + days_inactive=None, 104 + is_stale=True, 105 + ) 106 + 107 + # Get author feed to find last post 108 + feed_response = client.get_author_feed(actor=handle, limit=1) 109 + 110 + last_post_date = None 111 + if feed_response.feed: 112 + last_post = feed_response.feed[0].post 113 + if hasattr(last_post.record, "created_at"): 114 + created_at_str = last_post.record.created_at 115 + # Parse ISO 8601 timestamp 116 + last_post_date = datetime.fromisoformat( 117 + created_at_str.replace("Z", "+00:00") 118 + ) 119 + 120 + # Calculate days inactive 121 + if last_post_date: 122 + days_inactive = (datetime.now(timezone.utc) - last_post_date).days 123 + is_stale = days_inactive > inactivity_threshold_days 124 + else: 125 + # Has posts but couldn't determine date - consider stale 126 + days_inactive = None 127 + is_stale = True 128 + 129 + return AccountActivity( 130 + handle=handle, 131 + display_name=display_name, 132 + did=did, 133 + posts_count=posts_count, 134 + last_post_date=last_post_date, 135 + days_inactive=days_inactive, 136 + is_stale=is_stale, 137 + ) 138 + 139 + except Exception as e: 140 + # If we can't check activity, mark as potentially problematic 141 + # (could be deleted, suspended, or private) 142 + return AccountActivity( 143 + handle=handle, 144 + display_name=display_name, 145 + did=did, 146 + posts_count=0, 147 + last_post_date=None, 148 + days_inactive=None, 149 + is_stale=True, 150 + ) 151 + 152 + 153 + def format_account_link(handle: str) -> str: 154 + """Format a clickable Bluesky profile link""" 155 + return f"https://bsky.app/profile/{handle}" 156 + 157 + 158 + def main(inactivity_threshold_days: int): 159 + """Main function to find stale accounts""" 160 + console = Console() 161 + 162 + try: 163 + settings = Settings() # type: ignore 164 + except Exception as e: 165 + console.print( 166 + f"[red]Error loading settings (ensure .env file exists with BSKY_HANDLE and BSKY_PASSWORD): {e}[/red]" 167 + ) 168 + return 169 + 170 + client = Client(base_url=settings.bsky_pds_url) 171 + try: 172 + client.login(settings.bsky_handle, settings.bsky_password) 173 + except Exception as e: 174 + console.print(f"[red]Error logging into Bluesky: {e}[/red]") 175 + return 176 + 177 + console.print(f"[blue]Logged in as {client.me.handle}[/blue]") 178 + console.print( 179 + f"[blue]Checking for accounts inactive for more than {inactivity_threshold_days} days...[/blue]\n" 180 + ) 181 + 182 + # Fetch following list 183 + with Progress( 184 + SpinnerColumn(), 185 + TextColumn("[progress.description]{task.description}"), 186 + console=console, 187 + ) as progress: 188 + task = progress.add_task("Fetching following list...", total=None) 189 + following = get_following_list(client) 190 + progress.update(task, completed=True) 191 + 192 + console.print(f"[green]Found {len(following)} accounts you follow[/green]\n") 193 + 194 + # Check activity for each account 195 + stale_accounts = [] 196 + with Progress( 197 + SpinnerColumn(), 198 + TextColumn("[progress.description]{task.description}"), 199 + console=console, 200 + ) as progress: 201 + task = progress.add_task("Analyzing account activity...", total=len(following)) 202 + 203 + for actor in following: 204 + activity = check_account_activity( 205 + client, actor, inactivity_threshold_days 206 + ) 207 + if activity.is_stale: 208 + stale_accounts.append(activity) 209 + progress.advance(task) 210 + 211 + # Display results 212 + console.print(f"\n[yellow]Found {len(stale_accounts)} stale accounts:[/yellow]\n") 213 + 214 + if stale_accounts: 215 + table = Table(show_header=True, header_style="bold magenta") 216 + table.add_column("Handle", style="cyan") 217 + table.add_column("Display Name", style="white") 218 + table.add_column("Posts", justify="right", style="blue") 219 + table.add_column("Last Post", style="yellow") 220 + table.add_column("Days Inactive", justify="right", style="red") 221 + 222 + # Sort by days inactive (None values last) 223 + stale_accounts.sort( 224 + key=lambda x: (x.days_inactive is None, x.days_inactive or 0), 225 + reverse=True, 226 + ) 227 + 228 + for account in stale_accounts: 229 + last_post = ( 230 + account.last_post_date.strftime("%Y-%m-%d") 231 + if account.last_post_date 232 + else "Never" 233 + ) 234 + days = str(account.days_inactive) if account.days_inactive else "Unknown" 235 + 236 + table.add_row( 237 + f"@{account.handle}", 238 + account.display_name or "[dim]No name[/dim]", 239 + str(account.posts_count), 240 + last_post, 241 + days, 242 + ) 243 + 244 + console.print(table) 245 + 246 + # Print links for easy access 247 + console.print("\n[dim]Profile links:[/dim]") 248 + for account in stale_accounts[:10]: # Limit to first 10 249 + console.print(f" {format_account_link(account.handle)}") 250 + if len(stale_accounts) > 10: 251 + console.print(f" [dim]... and {len(stale_accounts) - 10} more[/dim]") 252 + else: 253 + console.print("[green]All accounts you follow are active![/green]") 254 + 255 + 256 + if __name__ == "__main__": 257 + parser = argparse.ArgumentParser( 258 + description="Find stale/inactive accounts you follow on Bluesky." 259 + ) 260 + parser.add_argument( 261 + "--days", 262 + type=int, 263 + default=180, 264 + help="Number of days of inactivity to consider an account stale (default: 180)", 265 + ) 266 + args = parser.parse_args() 267 + 268 + main(args.days)
+1 -1
update-lights
··· 37 hue_bridge_username: str = Field(default=...) 38 anthropic_api_key: str | None = Field(default=None) 39 40 - ai_model: KnownModelName = Field(default="anthropic:claude-sonnet-4-5") 41 42 43 settings = Settings()
··· 37 hue_bridge_username: str = Field(default=...) 38 anthropic_api_key: str | None = Field(default=None) 39 40 + ai_model: KnownModelName = Field(default="anthropic:claude-opus-4-5") 41 42 43 settings = Settings()