import argparse import logging import sys import time from atproto import Client # --- Logging --- logging.basicConfig( format="%(asctime)s [%(levelname)s] %(message)s", level=logging.INFO, stream=sys.stdout ) # ────────────────────────────────────────────────────────────────────────────── # AUTH # ────────────────────────────────────────────────────────────────────────────── def login(email: str, password: str) -> Client: client = Client() try: logging.info(f"Attempting login: {email}") client.login(email, password) logging.info("✅ Login successful!") return client except Exception as e: logging.error(f"❌ Login failed: {e}") sys.exit(1) # ────────────────────────────────────────────────────────────────────────────── # FETCH DATA # ────────────────────────────────────────────────────────────────────────────── def get_all_followers(client, handle: str) -> set: """Returns a set of DIDs of everyone who follows you.""" logging.info("📋 Fetching your followers...") followers = set() cursor = None while True: try: params = {'actor': handle, 'cursor': cursor, 'limit': 100} res = client.app.bsky.graph.get_followers(params) for user in res.followers: followers.add(user.did) if not res.cursor: break cursor = res.cursor time.sleep(0.3) except Exception as e: logging.warning(f"⚠️ Error fetching followers: {e}") break logging.info(f"👥 You have {len(followers)} followers.") return followers def get_all_following(client, handle: str) -> dict: """Returns {did: user_object} of everyone you follow.""" logging.info("📋 Fetching accounts you follow...") following = {} cursor = None while True: try: params = {'actor': handle, 'cursor': cursor, 'limit': 100} res = client.app.bsky.graph.get_follows(params) for user in res.follows: following[user.did] = user if not res.cursor: break cursor = res.cursor time.sleep(0.3) except Exception as e: logging.warning(f"⚠️ Error fetching following: {e}") break logging.info(f"➡️ You follow {len(following)} accounts.") return following # ────────────────────────────────────────────────────────────────────────────── # DISPLAY # ────────────────────────────────────────────────────────────────────────────── def print_user_list(users: dict): """Prints a numbered list of users to unfollow.""" print(f"\n📤 YOU FOLLOW — THEY DON'T FOLLOW BACK ({len(users)} total):") print("-" * 60) for i, user in enumerate(users.values(), start=1): display = f" ({user.display_name})" if user.display_name else "" print(f" {i:>4}. @{user.handle}{display}") print("-" * 60) # ────────────────────────────────────────────────────────────────────────────── # UNFOLLOW # ────────────────────────────────────────────────────────────────────────────── def unfollow_non_followers(client, to_unfollow: dict, dry_run: bool): """Prints the list, prompts for confirmation, then unfollows.""" if not to_unfollow: logging.info("✅ Everyone you follow also follows you back!") return # Always print the full list first print_user_list(to_unfollow) if dry_run: logging.info("🔍 Dry run — no changes made.") return # Confirmation prompt answer = input(f"\n❓ Unfollow these {len(to_unfollow)} accounts? (y/N): ").strip().lower() if answer != 'y': logging.info("❌ Cancelled — no one was unfollowed.") return logging.info("➖ Unfollowing...") success = 0 failed = 0 for did, user in to_unfollow.items(): try: if user.viewer and user.viewer.following: rkey = user.viewer.following.split("/")[-1] repo = user.viewer.following.split("/")[2] client.com.atproto.repo.delete_record({ "repo": repo, "collection": "app.bsky.graph.follow", "rkey": rkey }) logging.info(f" ✅ Unfollowed: @{user.handle}") success += 1 else: logging.warning(f" ⚠️ No follow record found for @{user.handle}") failed += 1 time.sleep(0.5) except Exception as e: logging.error(f" ❌ Failed to unfollow @{user.handle}: {e}") failed += 1 print("\n" + "=" * 60) print(f" ➖ Unfollowed : {success}") print(f" ❌ Failed : {failed}") print("=" * 60) # ────────────────────────────────────────────────────────────────────────────── # MAIN # ────────────────────────────────────────────────────────────────────────────── def main(): parser = argparse.ArgumentParser( description="Unfollow everyone on Bluesky who doesn't follow you back." ) parser.add_argument("bsky_email", help="Bluesky login email or handle") parser.add_argument("bsky_app_password", help="Bluesky app password (Settings > App Passwords)") parser.add_argument( "--dry_run", action="store_true", help="Print the list without unfollowing anyone" ) args = parser.parse_args() # --- Login --- client = login(args.bsky_email, args.bsky_app_password) my_handle = client.me.handle logging.info(f"👤 Logged in as: @{my_handle}") # --- Fetch both lists --- followers = get_all_followers(client, my_handle) following = get_all_following(client, my_handle) # --- Compute who doesn't follow back --- to_unfollow = { did: user for did, user in following.items() if did not in followers } # --- Summary --- print("\n" + "=" * 60) print("📊 SUMMARY") print("=" * 60) print(f" ➡️ Total following : {len(following)}") print(f" 👥 Total followers : {len(followers)}") print(f" 📤 Don't follow you back : {len(to_unfollow)}") print("=" * 60) # --- Unfollow --- unfollow_non_followers(client, to_unfollow, dry_run=args.dry_run) logging.info("🎉 All done!") if __name__ == "__main__": main()