Files
scripts/bsky/unfollow.py
Guillem Hernandez Sola 621ff10fbb bsky mgm
2026-04-09 11:53:37 +02:00

295 lines
12 KiB
Python

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()