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 FOLLOWERS / FOLLOWING # ────────────────────────────────────────────────────────────────────────────── def get_all_followers(client, handle: str) -> dict: logging.info("📋 Fetching your followers...") followers = {} 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[user.did] = user 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: 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 # ────────────────────────────────────────────────────────────────────────────── # DIFF # ────────────────────────────────────────────────────────────────────────────── def compute_diff(followers: dict, following: dict) -> dict: follower_dids = set(followers.keys()) following_dids = set(following.keys()) return { "mutual": {did: followers[did] for did in follower_dids & following_dids}, "not_followed": {did: followers[did] for did in follower_dids - following_dids}, "not_following": {did: following[did] for did in following_dids - follower_dids}, } # ────────────────────────────────────────────────────────────────────────────── # INTERACTION DETECTION # ────────────────────────────────────────────────────────────────────────────── def get_dids_who_interacted_with_me(client, my_did: str, my_handle: str, post_limit: int) -> set: """ Scans YOUR recent posts and collects DIDs of anyone who: - Liked one of your posts - Reposted one of your posts - Replied to one of your posts Returns a set of DIDs. """ interacted = set() logging.info(f"🔍 Scanning your posts for interactions (limit: {post_limit} posts)...") try: params = {'actor': my_handle, 'limit': min(post_limit, 100)} res = client.app.bsky.feed.get_author_feed(params) posts = res.feed except Exception as e: logging.warning(f"⚠️ Could not fetch your posts: {e}") return interacted for item in posts: post = item.post post_uri = post.uri post_cid = post.cid # --- Likes on your posts --- try: like_cursor = None while True: lres = client.app.bsky.feed.get_likes({ 'uri': post_uri, 'cid': post_cid, 'limit': 100, 'cursor': like_cursor }) for like in lres.likes: interacted.add(like.actor.did) if not lres.cursor: break like_cursor = lres.cursor time.sleep(0.2) except Exception: pass # --- Reposts of your posts --- try: repost_cursor = None while True: rres = client.app.bsky.feed.get_reposted_by({ 'uri': post_uri, 'cid': post_cid, 'limit': 100, 'cursor': repost_cursor }) for actor in rres.reposted_by: interacted.add(actor.did) if not rres.cursor: break repost_cursor = rres.cursor time.sleep(0.2) except Exception: pass # --- Replies to your posts --- try: thread_res = client.app.bsky.feed.get_post_thread({'uri': post_uri, 'depth': 1}) thread = thread_res.thread if hasattr(thread, 'replies') and thread.replies: for reply in thread.replies: if hasattr(reply, 'post') and reply.post.author.did != my_did: interacted.add(reply.post.author.did) except Exception: pass time.sleep(0.3) logging.info(f" 👤 Found {len(interacted)} unique users who interacted with your posts.") return interacted def get_dids_i_interacted_with(client, my_did: str, my_handle: str, post_limit: int) -> set: """ Scans YOUR recent activity and collects DIDs of users you have: - Liked their posts - Reposted their posts - Replied to their posts Returns a set of DIDs. """ interacted = set() logging.info(f"🔍 Scanning your activity for interactions you made (limit: {post_limit} posts)...") # --- Posts you liked --- try: like_cursor = None fetched = 0 while fetched < post_limit: lres = client.app.bsky.feed.get_actor_likes({ 'actor': my_handle, 'limit': 100, 'cursor': like_cursor }) for item in lres.feed: interacted.add(item.post.author.did) fetched += 1 if not lres.cursor or fetched >= post_limit: break like_cursor = lres.cursor time.sleep(0.3) logging.info(f" ❤️ Scanned {fetched} liked posts.") except Exception as e: logging.warning(f"⚠️ Could not fetch your likes: {e}") # --- Your own posts: reposts and replies --- try: params = {'actor': my_handle, 'limit': min(post_limit, 100)} res = client.app.bsky.feed.get_author_feed(params) for item in res.feed: record = item.post.record # Reposts (reason = repost) if hasattr(item, 'reason') and item.reason: reason_type = getattr(item.reason, 'py_type', '') or getattr(item.reason, '$type', '') if 'repost' in str(reason_type).lower(): interacted.add(item.post.author.did) # Replies if hasattr(record, 'reply') and record.reply: # Extract DID from the parent post URI: at://did:.../... parent_uri = record.reply.parent.uri parent_did = parent_uri.split("/")[2] if parent_did != my_did: interacted.add(parent_did) logging.info(f" 🔁 Scanned your posts for reposts and replies.") except Exception as e: logging.warning(f"⚠️ Could not fetch your feed for reposts/replies: {e}") logging.info(f" 👤 Found {len(interacted)} unique users you interacted with.") return interacted # ────────────────────────────────────────────────────────────────────────────── # DISPLAY # ────────────────────────────────────────────────────────────────────────────── def print_summary(diff: dict, followers: dict, following: dict): print("\n" + "=" * 60) print("📊 FOLLOWERS vs FOLLOWING — COMPARISON SUMMARY") print("=" * 60) print(f" 👥 Total followers : {len(followers)}") print(f" ➡️ Total following : {len(following)}") print("-" * 60) print(f" 🤝 Mutual (follow each other) : {len(diff['mutual'])}") print(f" 📥 Follow you, you don't back : {len(diff['not_followed'])}") print(f" 📤 You follow, they don't back: {len(diff['not_following'])}") print("=" * 60) def print_user_list(title: str, emoji: str, users: dict): print(f"\n{emoji} {title} ({len(users)} total):") print("-" * 60) if not users: print(" ✅ None.") else: 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) def prompt(question: str) -> bool: answer = input(f"\n{question} (y/N): ").strip().lower() return answer == 'y' # ────────────────────────────────────────────────────────────────────────────── # UNFOLLOW ONE-SIDED (with interaction protection) # ────────────────────────────────────────────────────────────────────────────── def unfollow_onesided(client, not_following: dict, protected_dids: set): """ From the one-sided follows, splits into: - protected : have interacted with you or you with them → KEEP - to_unfollow : no interaction detected → UNFOLLOW (after confirmation) """ if not not_following: logging.info("✅ Everyone you follow also follows you back!") return protected = {did: u for did, u in not_following.items() if did in protected_dids} to_unfollow = {did: u for did, u in not_following.items() if did not in protected_dids} # --- Print protected list --- print_user_list( "YOU FOLLOW — DON'T FOLLOW BACK — BUT HAVE INTERACTED (KEPT)", "🛡️", protected ) # --- Print unfollow list --- print_user_list( "YOU FOLLOW — DON'T FOLLOW BACK — NO INTERACTION (TO UNFOLLOW)", "📤", to_unfollow ) print(f"\n 🛡️ Protected (interaction detected) : {len(protected)}") print(f" 📤 Will be unfollowed : {len(to_unfollow)}") if not to_unfollow: logging.info("✅ No one to unfollow after interaction check.") return if not prompt(f"❓ Unfollow these {len(to_unfollow)} accounts with no interaction?"): logging.info("⏭️ Skipped — 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 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) # ────────────────────────────────────────────────────────────────────────────── # FOLLOW BACK # ────────────────────────────────────────────────────────────────────────────── def follow_back(client, not_followed: dict): if not not_followed: logging.info("✅ You already follow back everyone who follows you!") return print_user_list("FOLLOW YOU — NOT FOLLOWED BACK", "📥", not_followed) if not prompt(f"❓ Follow back these {len(not_followed)} accounts?"): logging.info("⏭️ Skipped — no one was followed back.") return logging.info("➕ Following back...") success = 0 failed = 0 for did, user in not_followed.items(): try: client.app.bsky.graph.follow.create( repo=client.me.did, record={ "$type": "app.bsky.graph.follow", "subject": did, "createdAt": client.get_current_time_iso() } ) logging.info(f" ✅ Followed back: @{user.handle}") success += 1 time.sleep(0.5) except Exception as e: logging.error(f" ❌ Failed to follow @{user.handle}: {e}") failed += 1 print("\n" + "=" * 60) print(f" ➕ Followed back : {success}") print(f" ❌ Failed : {failed}") print("=" * 60) # ────────────────────────────────────────────────────────────────────────────── # MAIN # ────────────────────────────────────────────────────────────────────────────── def main(): parser = argparse.ArgumentParser( description="Unfollow one-sided follows (protecting interacted users) and follow 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="Show all lists and decisions without making any changes" ) parser.add_argument( "--post_limit", type=int, default=100, help="How many recent posts to scan for interactions (default: 100)" ) args = parser.parse_args() # --- Login --- client = login(args.bsky_email, args.bsky_app_password) my_did = client.me.did 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 diff --- diff = compute_diff(followers, following) print_summary(diff, followers, following) # --- Collect interaction data --- print("\n── SCANNING INTERACTIONS ─────────────────────────────────") they_interacted = get_dids_who_interacted_with_me(client, my_did, my_handle, args.post_limit) i_interacted = get_dids_i_interacted_with(client, my_did, my_handle, args.post_limit) protected_dids = they_interacted | i_interacted logging.info(f"🛡️ Total protected DIDs (any interaction): {len(protected_dids)}") if args.dry_run: logging.info("🔍 Dry run — printing lists only, no changes made.\n") protected = {did: u for did, u in diff["not_following"].items() if did in protected_dids} to_unfollow = {did: u for did, u in diff["not_following"].items() if did not in protected_dids} print_user_list("PROTECTED — INTERACTION DETECTED (KEPT)", "🛡️", protected) print_user_list("NO INTERACTION — WOULD BE UNFOLLOWED", "📤", to_unfollow) print_user_list("FOLLOW YOU — WOULD BE FOLLOWED BACK", "📥", diff["not_followed"]) logging.info("🔍 Dry run complete.") return # --- Step 1: Unfollow one-sided (with interaction protection) --- print("\n── STEP 1 of 2 — UNFOLLOW ONE-SIDED (INTERACTION CHECK) ──") unfollow_onesided(client, diff["not_following"], protected_dids) # --- Step 2: Follow back --- print("\n── STEP 2 of 2 — FOLLOW BACK ─────────────────────────────") follow_back(client, diff["not_followed"]) print("\n" + "=" * 60) logging.info("🎉 All done!") print("=" * 60) if __name__ == "__main__": main()