···206206- uses [`atproto`](https://github.com/MarshalX/atproto) to fetch following list
207207- uses [`rich`](https://github.com/Textualize/rich) for pretty output
208208- identifies accounts with no recent posts
209209+- interactive unfollow support
209210210211---
211212
+254-117
find-stale-bsky-follows
···1818- uses [`atproto`](https://github.com/MarshalX/atproto) to fetch following list
1919- uses [`rich`](https://github.com/Textualize/rich) for pretty output
2020- identifies accounts with no recent posts
2121+- interactive unfollow support
2122"""
22232324import argparse
2425import os
2525-from datetime import datetime, timedelta, timezone
2626+from concurrent.futures import ThreadPoolExecutor, as_completed
2727+from datetime import datetime, timezone
2628from typing import NamedTuple
27292830from atproto import Client
···5456 last_post_date: datetime | None
5557 days_inactive: int | None
5658 is_stale: bool
5959+ follow_uri: str | None
576058615962def get_following_list(client: Client) -> list[dict]:
···7376 return following
747775787676-def check_account_activity(
7777- client: Client, actor: dict, inactivity_threshold_days: int
7878-) -> AccountActivity:
7979+def get_profiles_batched(
8080+ client: Client, actors: list[dict], progress, task
8181+) -> dict[str, tuple[int, str | None]]:
8282+ """Batch fetch profiles to get post counts and follow URIs.
8383+8484+ Returns dict mapping DID -> (posts_count, follow_uri)
7985 """
8080- Check the activity of a single account.
8686+ results = {}
8787+ dids = [a.did for a in actors]
8888+8989+ for i in range(0, len(dids), 25):
9090+ batch = dids[i : i + 25]
9191+ response = client.get_profiles(batch)
9292+ for profile in response.profiles:
9393+ posts_count = profile.posts_count or 0
9494+ follow_uri = None
9595+ if hasattr(profile, "viewer") and profile.viewer:
9696+ follow_uri = getattr(profile.viewer, "following", None)
9797+ results[profile.did] = (posts_count, follow_uri)
9898+ progress.advance(task, len(batch))
81998282- Returns AccountActivity with stale status based on:
8383- - No posts at all
8484- - Last post older than threshold
8585- """
8686- handle = actor.handle
8787- did = actor.did
8888- display_name = getattr(actor, "display_name", None)
100100+ return results
101101+89102103103+def check_last_post(
104104+ client: Client, handle: str
105105+) -> datetime | None:
106106+ """Check a single account's last post date via author feed."""
90107 try:
9191- # Get the user's profile to check post count
9292- profile = client.get_profile(handle)
9393- posts_count = profile.posts_count or 0
108108+ feed_response = client.get_author_feed(
109109+ actor=handle, limit=1, filter="posts_with_replies"
110110+ )
111111+ if feed_response.feed:
112112+ feed_item = feed_response.feed[0]
113113+ if hasattr(feed_item, "reason") and feed_item.reason:
114114+ if hasattr(feed_item.reason, "indexed_at"):
115115+ return datetime.fromisoformat(
116116+ feed_item.reason.indexed_at.replace("Z", "+00:00")
117117+ )
118118+ elif hasattr(feed_item.post.record, "created_at"):
119119+ return datetime.fromisoformat(
120120+ feed_item.post.record.created_at.replace("Z", "+00:00")
121121+ )
122122+ except Exception:
123123+ pass
124124+ return None
125125+126126+127127+def find_stale_accounts(
128128+ client: Client, following: list[dict], inactivity_threshold_days: int, console: Console
129129+) -> list[AccountActivity]:
130130+ """Find stale accounts using batched profiles + concurrent feed checks."""
131131+ now = datetime.now(timezone.utc)
941329595- # If no posts, immediately mark as stale
133133+ # phase 1: batch fetch profiles for post counts and follow URIs
134134+ with Progress(
135135+ SpinnerColumn(),
136136+ TextColumn("[progress.description]{task.description}"),
137137+ console=console,
138138+ ) as progress:
139139+ task = progress.add_task(
140140+ "Fetching profiles...", total=len(following)
141141+ )
142142+ profile_data = get_profiles_batched(client, following, progress, task)
143143+144144+ # build actor lookup
145145+ actor_by_did = {a.did: a for a in following}
146146+147147+ # separate zero-post accounts (immediately stale) from those needing feed check
148148+ stale = []
149149+ needs_feed_check = []
150150+ for did, (posts_count, follow_uri) in profile_data.items():
151151+ actor = actor_by_did.get(did)
152152+ if not actor:
153153+ continue
154154+ display_name = getattr(actor, "display_name", None)
155155+96156 if posts_count == 0:
9797- return AccountActivity(
9898- handle=handle,
9999- display_name=display_name,
100100- did=did,
101101- posts_count=0,
102102- last_post_date=None,
103103- days_inactive=None,
104104- is_stale=True,
157157+ stale.append(
158158+ AccountActivity(
159159+ handle=actor.handle,
160160+ display_name=display_name,
161161+ did=did,
162162+ posts_count=0,
163163+ last_post_date=None,
164164+ days_inactive=None,
165165+ is_stale=True,
166166+ follow_uri=follow_uri,
167167+ )
105168 )
169169+ else:
170170+ needs_feed_check.append((actor, posts_count, follow_uri))
106171107107- # Get author feed to find last post
108108- feed_response = client.get_author_feed(actor=handle, limit=1)
172172+ # phase 2: concurrent feed checks for accounts with posts
173173+ with Progress(
174174+ SpinnerColumn(),
175175+ TextColumn("[progress.description]{task.description}"),
176176+ console=console,
177177+ ) as progress:
178178+ task = progress.add_task(
179179+ "Checking recent activity...", total=len(needs_feed_check)
180180+ )
109181110110- last_post_date = None
111111- if feed_response.feed:
112112- last_post = feed_response.feed[0].post
113113- if hasattr(last_post.record, "created_at"):
114114- created_at_str = last_post.record.created_at
115115- # Parse ISO 8601 timestamp
116116- last_post_date = datetime.fromisoformat(
117117- created_at_str.replace("Z", "+00:00")
182182+ with ThreadPoolExecutor(max_workers=10) as pool:
183183+ futures = {
184184+ pool.submit(check_last_post, client, actor.handle): (
185185+ actor,
186186+ posts_count,
187187+ follow_uri,
118188 )
189189+ for actor, posts_count, follow_uri in needs_feed_check
190190+ }
119191120120- # Calculate days inactive
121121- if last_post_date:
122122- days_inactive = (datetime.now(timezone.utc) - last_post_date).days
123123- is_stale = days_inactive > inactivity_threshold_days
192192+ for future in as_completed(futures):
193193+ actor, posts_count, follow_uri = futures[future]
194194+ display_name = getattr(actor, "display_name", None)
195195+ last_post_date = future.result()
196196+197197+ if last_post_date:
198198+ days_inactive = (now - last_post_date).days
199199+ is_stale = days_inactive > inactivity_threshold_days
200200+ else:
201201+ days_inactive = None
202202+ is_stale = True
203203+204204+ if is_stale:
205205+ stale.append(
206206+ AccountActivity(
207207+ handle=actor.handle,
208208+ display_name=display_name,
209209+ did=actor.did,
210210+ posts_count=posts_count,
211211+ last_post_date=last_post_date,
212212+ days_inactive=days_inactive,
213213+ is_stale=True,
214214+ follow_uri=follow_uri,
215215+ )
216216+ )
217217+ progress.advance(task)
218218+219219+ return stale
220220+221221+222222+def display_stale_accounts(console: Console, stale_accounts: list[AccountActivity]):
223223+ """Display stale accounts in a numbered table."""
224224+ table = Table(show_header=True, header_style="bold magenta")
225225+ table.add_column("#", style="dim", justify="right")
226226+ table.add_column("Handle", style="cyan")
227227+ table.add_column("Display Name", style="white")
228228+ table.add_column("Posts", justify="right", style="blue")
229229+ table.add_column("Last Post", style="yellow")
230230+ table.add_column("Days Inactive", justify="right", style="red")
231231+232232+ for i, account in enumerate(stale_accounts, 1):
233233+ last_post = (
234234+ account.last_post_date.strftime("%Y-%m-%d")
235235+ if account.last_post_date
236236+ else "Never"
237237+ )
238238+ days = str(account.days_inactive) if account.days_inactive else "Unknown"
239239+240240+ table.add_row(
241241+ str(i),
242242+ f"@{account.handle}",
243243+ account.display_name or "[dim]No name[/dim]",
244244+ str(account.posts_count),
245245+ last_post,
246246+ days,
247247+ )
248248+249249+ console.print(table)
250250+251251+252252+def interactive_unfollow(
253253+ client: Client, console: Console, stale_accounts: list[AccountActivity]
254254+):
255255+ """Interactively select and unfollow stale accounts."""
256256+ while True:
257257+ console.print(
258258+ "\n[bold]Enter numbers to unfollow (comma-separated), "
259259+ "'all' for all, or 'q' to quit:[/bold] ",
260260+ end="",
261261+ )
262262+ try:
263263+ choice = input().strip()
264264+ except (EOFError, KeyboardInterrupt):
265265+ console.print()
266266+ break
267267+268268+ if not choice or choice.lower() == "q":
269269+ break
270270+271271+ if choice.lower() == "all":
272272+ selected = list(range(len(stale_accounts)))
124273 else:
125125- # Has posts but couldn't determine date - consider stale
126126- days_inactive = None
127127- is_stale = True
274274+ try:
275275+ selected = [int(x.strip()) - 1 for x in choice.split(",")]
276276+ if any(i < 0 or i >= len(stale_accounts) for i in selected):
277277+ console.print("[red]Invalid number(s). Try again.[/red]")
278278+ continue
279279+ except ValueError:
280280+ console.print("[red]Invalid input. Enter numbers, 'all', or 'q'.[/red]")
281281+ continue
282282+283283+ # show what will be unfollowed
284284+ to_unfollow = [stale_accounts[i] for i in selected]
285285+ missing_uri = [a for a in to_unfollow if not a.follow_uri]
286286+ to_unfollow = [a for a in to_unfollow if a.follow_uri]
287287+288288+ if missing_uri:
289289+ console.print(
290290+ f"[yellow]Skipping {len(missing_uri)} account(s) with missing follow URI[/yellow]"
291291+ )
292292+293293+ if not to_unfollow:
294294+ console.print("[yellow]Nothing to unfollow.[/yellow]")
295295+ continue
296296+297297+ console.print(f"\n[yellow]Will unfollow {len(to_unfollow)} account(s):[/yellow]")
298298+ for a in to_unfollow:
299299+ console.print(f" @{a.handle} ({a.display_name or 'No name'})")
128300129129- return AccountActivity(
130130- handle=handle,
131131- display_name=display_name,
132132- did=did,
133133- posts_count=posts_count,
134134- last_post_date=last_post_date,
135135- days_inactive=days_inactive,
136136- is_stale=is_stale,
301301+ console.print("\n[bold]Confirm? (y/n):[/bold] ", end="")
302302+ try:
303303+ confirm = input().strip().lower()
304304+ except (EOFError, KeyboardInterrupt):
305305+ console.print()
306306+ break
307307+308308+ if confirm != "y":
309309+ console.print("[dim]Cancelled.[/dim]")
310310+ continue
311311+312312+ # execute unfollows
313313+ unfollowed = []
314314+ for account in to_unfollow:
315315+ try:
316316+ client.unfollow(account.follow_uri)
317317+ unfollowed.append(account)
318318+ console.print(f" [green]Unfollowed @{account.handle}[/green]")
319319+ except Exception as e:
320320+ console.print(f" [red]Failed to unfollow @{account.handle}: {e}[/red]")
321321+322322+ console.print(
323323+ f"\n[green]Unfollowed {len(unfollowed)}/{len(to_unfollow)} account(s).[/green]"
137324 )
138325139139- except Exception as e:
140140- # If we can't check activity, mark as potentially problematic
141141- # (could be deleted, suspended, or private)
142142- return AccountActivity(
143143- handle=handle,
144144- display_name=display_name,
145145- did=did,
146146- posts_count=0,
147147- last_post_date=None,
148148- days_inactive=None,
149149- is_stale=True,
150150- )
326326+ # remove unfollowed from the list and re-display if any remain
327327+ unfollowed_dids = {a.did for a in unfollowed}
328328+ stale_accounts[:] = [a for a in stale_accounts if a.did not in unfollowed_dids]
151329330330+ if not stale_accounts:
331331+ console.print("[green]No stale accounts remaining.[/green]")
332332+ break
152333153153-def format_account_link(handle: str) -> str:
154154- """Format a clickable Bluesky profile link"""
155155- return f"https://bsky.app/profile/{handle}"
334334+ console.print(f"\n[yellow]{len(stale_accounts)} stale account(s) remaining:[/yellow]\n")
335335+ display_stale_accounts(console, stale_accounts)
156336157337158338def main(inactivity_threshold_days: int):
···191371192372 console.print(f"[green]Found {len(following)} accounts you follow[/green]\n")
193373194194- # Check activity for each account
195195- stale_accounts = []
196196- with Progress(
197197- SpinnerColumn(),
198198- TextColumn("[progress.description]{task.description}"),
199199- console=console,
200200- ) as progress:
201201- task = progress.add_task("Analyzing account activity...", total=len(following))
374374+ stale_accounts = find_stale_accounts(
375375+ client, following, inactivity_threshold_days, console
376376+ )
202377203203- for actor in following:
204204- activity = check_account_activity(
205205- client, actor, inactivity_threshold_days
206206- )
207207- if activity.is_stale:
208208- stale_accounts.append(activity)
209209- progress.advance(task)
378378+ # Sort by days inactive (None values last)
379379+ stale_accounts.sort(
380380+ key=lambda x: (x.days_inactive is None, x.days_inactive or 0),
381381+ reverse=True,
382382+ )
210383211211- # Display results
212384 console.print(f"\n[yellow]Found {len(stale_accounts)} stale accounts:[/yellow]\n")
213385214386 if stale_accounts:
215215- table = Table(show_header=True, header_style="bold magenta")
216216- table.add_column("Handle", style="cyan")
217217- table.add_column("Display Name", style="white")
218218- table.add_column("Posts", justify="right", style="blue")
219219- table.add_column("Last Post", style="yellow")
220220- table.add_column("Days Inactive", justify="right", style="red")
221221-222222- # Sort by days inactive (None values last)
223223- stale_accounts.sort(
224224- key=lambda x: (x.days_inactive is None, x.days_inactive or 0),
225225- reverse=True,
226226- )
227227-228228- for account in stale_accounts:
229229- last_post = (
230230- account.last_post_date.strftime("%Y-%m-%d")
231231- if account.last_post_date
232232- else "Never"
233233- )
234234- days = str(account.days_inactive) if account.days_inactive else "Unknown"
235235-236236- table.add_row(
237237- f"@{account.handle}",
238238- account.display_name or "[dim]No name[/dim]",
239239- str(account.posts_count),
240240- last_post,
241241- days,
242242- )
243243-244244- console.print(table)
245245-246246- # Print links for easy access
247247- console.print("\n[dim]Profile links:[/dim]")
248248- for account in stale_accounts[:10]: # Limit to first 10
249249- console.print(f" {format_account_link(account.handle)}")
250250- if len(stale_accounts) > 10:
251251- console.print(f" [dim]... and {len(stale_accounts) - 10} more[/dim]")
387387+ display_stale_accounts(console, stale_accounts)
388388+ interactive_unfollow(client, console, stale_accounts)
252389 else:
253390 console.print("[green]All accounts you follow are active![/green]")
254391