diff --git a/bsky/unfollow_non_unfollowers.py b/bsky/unfollow_non_unfollowers.py new file mode 100644 index 0000000..d507de7 --- /dev/null +++ b/bsky/unfollow_non_unfollowers.py @@ -0,0 +1,196 @@ +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() \ No newline at end of file