+20
README.md
+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
+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
+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)