for assorted things
at main 8.3 kB view raw
1#!/usr/bin/env -S uv run --script --quiet 2# /// script 3# requires-python = ">=3.12" 4# dependencies = ["atproto", "pydantic-settings", "rich"] 5# /// 6""" 7Find stale/inactive accounts among those you follow on Bluesky. 8 9Usage: 10 11```bash 12./find-stale-bsky-follows 13# or with custom inactivity threshold (days) 14./find-stale-bsky-follows --days 180 15``` 16 17Details: 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 23import argparse 24import os 25from datetime import datetime, timedelta, timezone 26from typing import NamedTuple 27 28from atproto import Client 29from pydantic_settings import BaseSettings, SettingsConfigDict 30from rich.console import Console 31from rich.progress import Progress, SpinnerColumn, TextColumn 32from rich.table import Table 33 34 35class 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 47class 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 59def 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 76def 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 153def format_account_link(handle: str) -> str: 154 """Format a clickable Bluesky profile link""" 155 return f"https://bsky.app/profile/{handle}" 156 157 158def 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 256if __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)