import argparse import logging import sys import csv import time from atproto import Client # --- Logging --- logging.basicConfig( format="%(asctime)s [%(levelname)s] %(message)s", level=logging.INFO, stream=sys.stdout ) def get_all_interactions(client, handle): """ Fetches all historical interactions. Returns: - interacted_users: dict of everyone you interacted with (to protect from unfollow) - fans: dict of users who specifically liked or reposted YOUR posts (to potentially follow) """ interacted_users = {} fans = {} # 1. Fetch Likes (Posts YOU liked) logging.info("Fetching all historical likes...") cursor = None while True: try: params = {'actor': handle, 'cursor': cursor, 'limit': 100} # Adjusted to include parameters res = client.app.bsky.feed.get_actor_likes(params) # Corrected to pass params as a dictionary for item in res.feed: interacted_users[item.post.author.did] = item.post.author.handle if not res.cursor: break cursor = res.cursor except Exception as e: logging.warning(f"Error fetching likes: {e}") break # 2. Fetch Reposts and Replies (from YOUR feed) logging.info("Fetching all historical reposts and replies...") cursor = None while True: try: params = {'actor': handle, 'cursor': cursor, 'limit': 100} # Adjusted to include parameters res = client.app.bsky.feed.get_author_feed(params) # Corrected to pass params as a dictionary for item in res.feed: if item.post.author.did != client.me.did: interacted_users[item.post.author.did] = item.post.author.handle if item.reply: interacted_users[item.reply.parent.author.did] = item.reply.parent.author.handle interacted_users[item.reply.root.author.did] = item.reply.root.author.handle if not res.cursor: break cursor = res.cursor except Exception as e: logging.warning(f"Error fetching feed: {e}") break # 3. Fetch Notifications (Mentions, Replies, Likes, Reposts TO YOU) logging.info("Fetching all historical notifications (likes, reposts, mentions)...") cursor = None while True: try: params = {'cursor': cursor, 'limit': 100} # Adjusted to include parameters res = client.app.bsky.notification.list_notifications(params) # Corrected to pass params as a dictionary for notif in res.notifications: # Add to general interactions (protects from unfollow) if notif.reason in ['mention', 'reply', 'quote', 'like', 'repost']: interacted_users[notif.author.did] = notif.author.handle # Specifically track people who liked or reposted your posts if notif.reason in ['like', 'repost']: fans[notif.author.did] = notif.author.handle if not res.cursor: break cursor = res.cursor except Exception as e: logging.warning(f"Error fetching notifications: {e}") break # Remove yourself from the lists if present if client.me.did in interacted_users: del interacted_users[client.me.did] if client.me.did in fans: del fans[client.me.did] logging.info(f"Found {len(interacted_users)} unique users you have interacted with.") logging.info(f"Found {len(fans)} unique users who have liked or reposted your posts.") return interacted_users, fans def get_all_following(client, handle): """Fetches the complete list of users you are following.""" logging.info("Fetching your complete following list...") following = [] cursor = None while True: try: params = {'actor': handle, 'cursor': cursor, 'limit': 100} # Adjusted to include parameters res = client.app.bsky.graph.get_follows(params) # Corrected to pass params as a dictionary following.extend(res.follows) if not res.cursor: break cursor = res.cursor except Exception as e: logging.warning(f"Error fetching following list: {e}") break return following def download_following_list(following, filename): """Saves the following list to a CSV file.""" with open(filename, mode='w', newline='', encoding='utf-8') as file: writer = csv.writer(file) writer.writerow(['DID', 'Handle', 'Display Name', 'Description']) for user in following: writer.writerow([user.did, user.handle, user.display_name, user.description]) logging.info(f"Downloaded following list to {filename}") def follow_new_fans(client, fans): """Follows users who liked/reposted your posts.""" to_follow = list(fans.items()) if not to_follow: logging.info("You already follow everyone who has liked or reposted your posts!") return print("\n" + "="*50) print(f"💙 USERS WHO LIKED/REPOSTED YOU (TO FOLLOW) ({len(to_follow)} total) 💙") print("="*50) for did, handle in to_follow: print(f"- @{handle}") print("="*50) confirm = input(f"\nDo you want to FOLLOW these {len(to_follow)} users? (y/N): ") if confirm.lower() != 'y': logging.info("Skipping following new fans.") return logging.info("Proceeding to follow users...") success_count = 0 for did, handle in to_follow: try: client.follow(did) logging.info(f"✅ Followed: @{handle}") success_count += 1 time.sleep(0.5) # Rate limit protection except Exception as e: logging.error(f"❌ Failed to follow @{handle}: {e}") logging.info(f"Finished! Successfully followed {success_count} users.") def list_interacted_but_not_followed(interacted_users, following, output_csv="interacted_not_followed.csv"): """Finds users you interacted with but do not follow.""" followed_dids = {user.did for user in following} not_followed = [(did, handle) for did, handle in interacted_users.items() if did not in followed_dids] if not not_followed: return with open(output_csv, mode='w', newline='', encoding='utf-8') as file: writer = csv.writer(file) writer.writerow(['DID', 'Handle']) for did, handle in not_followed: writer.writerow([did, handle]) logging.info(f"Saved the full list of interacted-but-not-followed users to {output_csv}") def has_language_in_profile(user): """Checks if the user explicitly mentions Catalan or Spanish in their bio.""" if not user.description: return False desc = user.description.lower() ca_keywords = ['català', 'catala', 'catalunya', '📍 cat', 'ca-es', 'catalan'] es_keywords = ['español', 'castellano', 'españa', '📍 es', 'es-es', 'spanish'] for kw in ca_keywords + es_keywords: if kw in desc: return True return False def posts_in_target_languages(client, did): """ Fetches the user's recent posts and checks the internal Bluesky language tags. Returns True if they recently posted in Catalan ('ca') or Spanish ('es'). """ try: res = client.app.bsky.feed.get_author_feed(actor=did, limit=15) for item in res.feed: record = item.post.record if hasattr(record, 'langs') and record.langs: for lang in record.langs: if lang.startswith('ca') or lang.startswith('es'): return True except Exception as e: logging.warning(f"Could not fetch recent posts for {did} to check language: {e}") return False def clean_users(client, following, interacted_users): users_to_unfollow = [] logging.info("Analyzing following list against your criteria (this may take a moment to check recent posts)...") for i, user in enumerate(following): if i > 0 and i % 50 == 0: logging.info(f"Processed {i}/{len(following)} users...") # 1. Keep if interacted with (This now INCLUDES people who liked/reposted your posts) if user.did in interacted_users: continue # 2. Keep if language is in profile bio if has_language_in_profile(user): continue # 3. Keep if they post/interact in Catalan or Spanish if posts_in_target_languages(client, user.did): continue # If none of the above match, add to unfollow list users_to_unfollow.append(user) if not users_to_unfollow: logging.info("No users found to unfollow based on your criteria. You're all clean!") return # --- HUMAN APPROVAL STEP --- print("\n" + "="*50) print(f"🛑 USERS TO UNFOLLOW ({len(users_to_unfollow)} total) 🛑") print("="*50) for u in users_to_unfollow: display = u.display_name or "" print(f"- @{u.handle} ({display})") print("="*50) confirm = input(f"\nDo you want to proceed and UNFOLLOW these {len(users_to_unfollow)} users? (y/N): ") if confirm.lower() != 'y': logging.info("Operation cancelled by user. No one was unfollowed.") return logging.info("Proceeding with unfollows...") success_count = 0 for user in users_to_unfollow: try: if user.viewer and user.viewer.following: client.app.bsky.graph.delete_follow(user.viewer.following) logging.info(f"✅ Unfollowed: @{user.handle}") success_count += 1 time.sleep(0.5) # Rate limit protection else: logging.warning(f"⚠️ Could not find follow record for @{user.handle}") except Exception as e: logging.error(f"❌ Failed to unfollow @{user.handle}: {e}") logging.info(f"Finished! Successfully unfollowed {success_count} users.") def main(): parser = argparse.ArgumentParser(description="Clean up your Bsky users.") parser.add_argument("bsky_username", help="Bluesky username (e.g., user.bsky.social)") parser.add_argument("bsky_app_password", help="Bluesky app password") parser.add_argument("--output_csv", default="following_list.csv", help="Output CSV file name for following list") parser.add_argument("--missed_csv", default="interacted_not_followed.csv", help="Output CSV for users you interacted with but don't follow") args = parser.parse_args() # --- Login --- client = Client() try: logging.info(f"Attempting login with user: {args.bsky_username}") client.login(args.bsky_username, args.bsky_app_password) logging.info("Login successful!") except Exception as e: logging.error(f"Login failed: {e}") sys.exit(1) # 1. Get all following following = get_all_following(client, args.bsky_username) download_following_list(following, args.output_csv) # 2. Get all historical interactions AND fans interacted_users, fans = get_all_interactions(client, args.bsky_username) # 3. Follow users who liked/reposted your posts follow_new_fans(client, fans) # 4. Save the full list of interacted-but-not-followed users to CSV list_interacted_but_not_followed(interacted_users, following, args.missed_csv) # 5. Filter and ask for human approval before unfollowing clean_users(client, following, interacted_users) if __name__ == "__main__": main()