for assorted things
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)