for assorted things

push updates to scripts ive used

+260 -119
+1
README.md
··· 206 206 - uses [`atproto`](https://github.com/MarshalX/atproto) to fetch following list 207 207 - uses [`rich`](https://github.com/Textualize/rich) for pretty output 208 208 - identifies accounts with no recent posts 209 + - interactive unfollow support 209 210 210 211 --- 211 212
+254 -117
find-stale-bsky-follows
··· 18 18 - uses [`atproto`](https://github.com/MarshalX/atproto) to fetch following list 19 19 - uses [`rich`](https://github.com/Textualize/rich) for pretty output 20 20 - identifies accounts with no recent posts 21 + - interactive unfollow support 21 22 """ 22 23 23 24 import argparse 24 25 import os 25 - from datetime import datetime, timedelta, timezone 26 + from concurrent.futures import ThreadPoolExecutor, as_completed 27 + from datetime import datetime, timezone 26 28 from typing import NamedTuple 27 29 28 30 from atproto import Client ··· 54 56 last_post_date: datetime | None 55 57 days_inactive: int | None 56 58 is_stale: bool 59 + follow_uri: str | None 57 60 58 61 59 62 def get_following_list(client: Client) -> list[dict]: ··· 73 76 return following 74 77 75 78 76 - def check_account_activity( 77 - client: Client, actor: dict, inactivity_threshold_days: int 78 - ) -> AccountActivity: 79 + def get_profiles_batched( 80 + client: Client, actors: list[dict], progress, task 81 + ) -> dict[str, tuple[int, str | None]]: 82 + """Batch fetch profiles to get post counts and follow URIs. 83 + 84 + Returns dict mapping DID -> (posts_count, follow_uri) 79 85 """ 80 - Check the activity of a single account. 86 + results = {} 87 + dids = [a.did for a in actors] 88 + 89 + for i in range(0, len(dids), 25): 90 + batch = dids[i : i + 25] 91 + response = client.get_profiles(batch) 92 + for profile in response.profiles: 93 + posts_count = profile.posts_count or 0 94 + follow_uri = None 95 + if hasattr(profile, "viewer") and profile.viewer: 96 + follow_uri = getattr(profile.viewer, "following", None) 97 + results[profile.did] = (posts_count, follow_uri) 98 + progress.advance(task, len(batch)) 81 99 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) 100 + return results 101 + 89 102 103 + def check_last_post( 104 + client: Client, handle: str 105 + ) -> datetime | None: 106 + """Check a single account's last post date via author feed.""" 90 107 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 108 + feed_response = client.get_author_feed( 109 + actor=handle, limit=1, filter="posts_with_replies" 110 + ) 111 + if feed_response.feed: 112 + feed_item = feed_response.feed[0] 113 + if hasattr(feed_item, "reason") and feed_item.reason: 114 + if hasattr(feed_item.reason, "indexed_at"): 115 + return datetime.fromisoformat( 116 + feed_item.reason.indexed_at.replace("Z", "+00:00") 117 + ) 118 + elif hasattr(feed_item.post.record, "created_at"): 119 + return datetime.fromisoformat( 120 + feed_item.post.record.created_at.replace("Z", "+00:00") 121 + ) 122 + except Exception: 123 + pass 124 + return None 125 + 126 + 127 + def find_stale_accounts( 128 + client: Client, following: list[dict], inactivity_threshold_days: int, console: Console 129 + ) -> list[AccountActivity]: 130 + """Find stale accounts using batched profiles + concurrent feed checks.""" 131 + now = datetime.now(timezone.utc) 94 132 95 - # If no posts, immediately mark as stale 133 + # phase 1: batch fetch profiles for post counts and follow URIs 134 + with Progress( 135 + SpinnerColumn(), 136 + TextColumn("[progress.description]{task.description}"), 137 + console=console, 138 + ) as progress: 139 + task = progress.add_task( 140 + "Fetching profiles...", total=len(following) 141 + ) 142 + profile_data = get_profiles_batched(client, following, progress, task) 143 + 144 + # build actor lookup 145 + actor_by_did = {a.did: a for a in following} 146 + 147 + # separate zero-post accounts (immediately stale) from those needing feed check 148 + stale = [] 149 + needs_feed_check = [] 150 + for did, (posts_count, follow_uri) in profile_data.items(): 151 + actor = actor_by_did.get(did) 152 + if not actor: 153 + continue 154 + display_name = getattr(actor, "display_name", None) 155 + 96 156 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, 157 + stale.append( 158 + AccountActivity( 159 + handle=actor.handle, 160 + display_name=display_name, 161 + did=did, 162 + posts_count=0, 163 + last_post_date=None, 164 + days_inactive=None, 165 + is_stale=True, 166 + follow_uri=follow_uri, 167 + ) 105 168 ) 169 + else: 170 + needs_feed_check.append((actor, posts_count, follow_uri)) 106 171 107 - # Get author feed to find last post 108 - feed_response = client.get_author_feed(actor=handle, limit=1) 172 + # phase 2: concurrent feed checks for accounts with posts 173 + with Progress( 174 + SpinnerColumn(), 175 + TextColumn("[progress.description]{task.description}"), 176 + console=console, 177 + ) as progress: 178 + task = progress.add_task( 179 + "Checking recent activity...", total=len(needs_feed_check) 180 + ) 109 181 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") 182 + with ThreadPoolExecutor(max_workers=10) as pool: 183 + futures = { 184 + pool.submit(check_last_post, client, actor.handle): ( 185 + actor, 186 + posts_count, 187 + follow_uri, 118 188 ) 189 + for actor, posts_count, follow_uri in needs_feed_check 190 + } 119 191 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 192 + for future in as_completed(futures): 193 + actor, posts_count, follow_uri = futures[future] 194 + display_name = getattr(actor, "display_name", None) 195 + last_post_date = future.result() 196 + 197 + if last_post_date: 198 + days_inactive = (now - last_post_date).days 199 + is_stale = days_inactive > inactivity_threshold_days 200 + else: 201 + days_inactive = None 202 + is_stale = True 203 + 204 + if is_stale: 205 + stale.append( 206 + AccountActivity( 207 + handle=actor.handle, 208 + display_name=display_name, 209 + did=actor.did, 210 + posts_count=posts_count, 211 + last_post_date=last_post_date, 212 + days_inactive=days_inactive, 213 + is_stale=True, 214 + follow_uri=follow_uri, 215 + ) 216 + ) 217 + progress.advance(task) 218 + 219 + return stale 220 + 221 + 222 + def display_stale_accounts(console: Console, stale_accounts: list[AccountActivity]): 223 + """Display stale accounts in a numbered table.""" 224 + table = Table(show_header=True, header_style="bold magenta") 225 + table.add_column("#", style="dim", justify="right") 226 + table.add_column("Handle", style="cyan") 227 + table.add_column("Display Name", style="white") 228 + table.add_column("Posts", justify="right", style="blue") 229 + table.add_column("Last Post", style="yellow") 230 + table.add_column("Days Inactive", justify="right", style="red") 231 + 232 + for i, account in enumerate(stale_accounts, 1): 233 + last_post = ( 234 + account.last_post_date.strftime("%Y-%m-%d") 235 + if account.last_post_date 236 + else "Never" 237 + ) 238 + days = str(account.days_inactive) if account.days_inactive else "Unknown" 239 + 240 + table.add_row( 241 + str(i), 242 + f"@{account.handle}", 243 + account.display_name or "[dim]No name[/dim]", 244 + str(account.posts_count), 245 + last_post, 246 + days, 247 + ) 248 + 249 + console.print(table) 250 + 251 + 252 + def interactive_unfollow( 253 + client: Client, console: Console, stale_accounts: list[AccountActivity] 254 + ): 255 + """Interactively select and unfollow stale accounts.""" 256 + while True: 257 + console.print( 258 + "\n[bold]Enter numbers to unfollow (comma-separated), " 259 + "'all' for all, or 'q' to quit:[/bold] ", 260 + end="", 261 + ) 262 + try: 263 + choice = input().strip() 264 + except (EOFError, KeyboardInterrupt): 265 + console.print() 266 + break 267 + 268 + if not choice or choice.lower() == "q": 269 + break 270 + 271 + if choice.lower() == "all": 272 + selected = list(range(len(stale_accounts))) 124 273 else: 125 - # Has posts but couldn't determine date - consider stale 126 - days_inactive = None 127 - is_stale = True 274 + try: 275 + selected = [int(x.strip()) - 1 for x in choice.split(",")] 276 + if any(i < 0 or i >= len(stale_accounts) for i in selected): 277 + console.print("[red]Invalid number(s). Try again.[/red]") 278 + continue 279 + except ValueError: 280 + console.print("[red]Invalid input. Enter numbers, 'all', or 'q'.[/red]") 281 + continue 282 + 283 + # show what will be unfollowed 284 + to_unfollow = [stale_accounts[i] for i in selected] 285 + missing_uri = [a for a in to_unfollow if not a.follow_uri] 286 + to_unfollow = [a for a in to_unfollow if a.follow_uri] 287 + 288 + if missing_uri: 289 + console.print( 290 + f"[yellow]Skipping {len(missing_uri)} account(s) with missing follow URI[/yellow]" 291 + ) 292 + 293 + if not to_unfollow: 294 + console.print("[yellow]Nothing to unfollow.[/yellow]") 295 + continue 296 + 297 + console.print(f"\n[yellow]Will unfollow {len(to_unfollow)} account(s):[/yellow]") 298 + for a in to_unfollow: 299 + console.print(f" @{a.handle} ({a.display_name or 'No name'})") 128 300 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, 301 + console.print("\n[bold]Confirm? (y/n):[/bold] ", end="") 302 + try: 303 + confirm = input().strip().lower() 304 + except (EOFError, KeyboardInterrupt): 305 + console.print() 306 + break 307 + 308 + if confirm != "y": 309 + console.print("[dim]Cancelled.[/dim]") 310 + continue 311 + 312 + # execute unfollows 313 + unfollowed = [] 314 + for account in to_unfollow: 315 + try: 316 + client.unfollow(account.follow_uri) 317 + unfollowed.append(account) 318 + console.print(f" [green]Unfollowed @{account.handle}[/green]") 319 + except Exception as e: 320 + console.print(f" [red]Failed to unfollow @{account.handle}: {e}[/red]") 321 + 322 + console.print( 323 + f"\n[green]Unfollowed {len(unfollowed)}/{len(to_unfollow)} account(s).[/green]" 137 324 ) 138 325 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 - ) 326 + # remove unfollowed from the list and re-display if any remain 327 + unfollowed_dids = {a.did for a in unfollowed} 328 + stale_accounts[:] = [a for a in stale_accounts if a.did not in unfollowed_dids] 151 329 330 + if not stale_accounts: 331 + console.print("[green]No stale accounts remaining.[/green]") 332 + break 152 333 153 - def format_account_link(handle: str) -> str: 154 - """Format a clickable Bluesky profile link""" 155 - return f"https://bsky.app/profile/{handle}" 334 + console.print(f"\n[yellow]{len(stale_accounts)} stale account(s) remaining:[/yellow]\n") 335 + display_stale_accounts(console, stale_accounts) 156 336 157 337 158 338 def main(inactivity_threshold_days: int): ··· 191 371 192 372 console.print(f"[green]Found {len(following)} accounts you follow[/green]\n") 193 373 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)) 374 + stale_accounts = find_stale_accounts( 375 + client, following, inactivity_threshold_days, console 376 + ) 202 377 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) 378 + # Sort by days inactive (None values last) 379 + stale_accounts.sort( 380 + key=lambda x: (x.days_inactive is None, x.days_inactive or 0), 381 + reverse=True, 382 + ) 210 383 211 - # Display results 212 384 console.print(f"\n[yellow]Found {len(stale_accounts)} stale accounts:[/yellow]\n") 213 385 214 386 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]") 387 + display_stale_accounts(console, stale_accounts) 388 + interactive_unfollow(client, console, stale_accounts) 252 389 else: 253 390 console.print("[green]All accounts you follow are active![/green]") 254 391
+5 -2
update-lights
··· 21 21 22 22 import marvin 23 23 import argparse 24 + from pathlib import Path 24 25 from pydantic_settings import BaseSettings, SettingsConfigDict 25 26 from pydantic import Field 26 27 from pydantic_ai.mcp import MCPServerStdio ··· 31 32 32 33 33 34 class Settings(BaseSettings): 34 - model_config = SettingsConfigDict(env_file=".env", extra="ignore") 35 + model_config = SettingsConfigDict( 36 + env_file=Path(__file__).parent / ".env", extra="ignore" 37 + ) 35 38 36 39 hue_bridge_ip: str = Field(default=...) 37 40 hue_bridge_username: str = Field(default=...) ··· 46 49 hub_mcp = MCPServerStdio( 47 50 command="uvx", 48 51 args=[ 49 - "smart-home@git+https://github.com/jlowin/fastmcp.git#subdirectory=examples/smart_home" 52 + "smart-home@git+https://github.com/jlowin/fastmcp.git@fix-phue2-import#subdirectory=examples/smart_home" 50 53 ], 51 54 env={ 52 55 "HUE_BRIDGE_IP": settings.hue_bridge_ip,