for assorted things

add find-stale-bsky-follows script and support custom PDS

- new script to identify inactive accounts you're following
- add BSKY_PDS_URL support to both bsky scripts

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

+20
README.md
··· 13 13 - [`check-files-for-bad-links`](#check-files-for-bad-links) 14 14 - [`dm-me-when-a-flight-passes-over`](#dm-me-when-a-flight-passes-over) 15 15 - [`find-longest-bsky-thread`](#find-longest-bsky-thread) 16 + - [`find-stale-bsky-follows`](#find-stale-bsky-follows) 16 17 - [`kill-processes`](#kill-processes) 17 18 - [`predict-github-stars`](#predict-github-stars) 18 19 - [`update-lights`](#update-lights) ··· 169 170 Details: 170 171 - uses [`atproto`](https://github.com/MarshalX/atproto) to fetch the thread 171 172 - uses [`jinja2`](https://github.com/pallets/jinja) to render the thread 173 + 174 + --- 175 + 176 + ### `find-stale-bsky-follows` 177 + 178 + Find stale/inactive accounts among those you follow on Bluesky. 179 + 180 + Usage: 181 + 182 + ```bash 183 + ./find-stale-bsky-follows 184 + # or with custom inactivity threshold (days) 185 + ./find-stale-bsky-follows --days 180 186 + ``` 187 + 188 + Details: 189 + - uses [`atproto`](https://github.com/MarshalX/atproto) to fetch following list 190 + - uses [`rich`](https://github.com/Textualize/rich) for pretty output 191 + - identifies accounts with no recent posts 172 192 173 193 --- 174 194
+2 -1
find-longest-bsky-thread
··· 38 38 39 39 bsky_handle: str 40 40 bsky_password: str 41 + bsky_pds_url: str = "https://bsky.social" 41 42 42 43 43 44 def extract_post_uri(bluesky_url: str) -> str: ··· 171 172 ) 172 173 return 173 174 174 - client = Client() 175 + client = Client(base_url=settings.bsky_pds_url) 175 176 try: 176 177 client.login(settings.bsky_handle, settings.bsky_password) 177 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)