diff --git a/bsky/followback.py b/bsky/followback.py new file mode 100644 index 0000000..534184a --- /dev/null +++ b/bsky/followback.py @@ -0,0 +1,159 @@ +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 +) + + +def login(email: str, password: str) -> Client: + """Login to Bluesky using email and app password.""" + client = Client() + try: + logging.info(f"Attempting login with email: {email}") + client.login(email, password) + logging.info("βœ… Login successful!") + return client + except Exception as e: + logging.error(f"❌ Login failed: {e}") + sys.exit(1) + + +def get_my_handle(client) -> str: + """Retrieve the authenticated user's handle.""" + return client.me.handle + + +def get_followers(client, handle: str) -> dict: + """Fetches all accounts that follow the user. Returns {handle: did}.""" + 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) + followers.extend(res.followers) + if not res.cursor: + break + cursor = res.cursor + time.sleep(0.3) + except Exception as e: + logging.warning(f"⚠️ Error fetching followers: {e}") + break + + result = {user.handle: user.did for user in followers} + logging.info(f"πŸ‘₯ You have {len(result)} followers.") + return result + + +def get_following(client, handle: str) -> set: + """Fetches all accounts the user follows. Returns a set of handles.""" + logging.info("πŸ“‹ Fetching accounts you follow...") + follows = [] + cursor = None + while True: + try: + params = {'actor': handle, 'cursor': cursor, 'limit': 100} + res = client.app.bsky.graph.get_follows(params) + follows.extend(res.follows) + if not res.cursor: + break + cursor = res.cursor + time.sleep(0.3) + except Exception as e: + logging.warning(f"⚠️ Error fetching following: {e}") + break + + result = {user.handle for user in follows} + logging.info(f"➑️ You are following {len(result)} accounts.") + return result + + +def follow_back(client, followers: dict, following: set) -> None: + """Follows back anyone who follows you but you don't follow back.""" + not_followed_back = { + handle: did + for handle, did in followers.items() + if handle not in following + } + + if not not_followed_back: + logging.info("πŸŽ‰ You already follow back everyone who follows you!") + return + + logging.info(f"βž• Found {len(not_followed_back)} followers to follow back...") + follow_count = 0 + fail_count = 0 + + for handle, did in not_followed_back.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: @{handle}") + follow_count += 1 + time.sleep(0.5) + except Exception as e: + logging.error(f" ❌ Failed to follow back @{handle}: {e}") + fail_count += 1 + + logging.info(f"βœ… Followed back {follow_count} accounts. ({fail_count} failed)") + + +def main(): + parser = argparse.ArgumentParser( + description="Follow back all Bluesky users who follow you but you don't follow back." + ) + parser.add_argument("bsky_email", help="Bluesky login email (e.g., you@email.com)") + parser.add_argument("bsky_app_password", help="Bluesky app password (from Settings > App Passwords)") + parser.add_argument("--dry_run", action="store_true", help="Preview who would be followed without actually following") + args = parser.parse_args() + + # --- Login --- + client = login(args.bsky_email, args.bsky_app_password) + my_handle = get_my_handle(client) + logging.info(f"πŸ‘€ Logged in as: @{my_handle}") + + # --- Fetch data --- + followers = get_followers(client, my_handle) + following = get_following(client, my_handle) + + # --- Diff --- + not_followed_back = { + handle: did + for handle, did in followers.items() + if handle not in following + } + + logging.info(f"πŸ“Š Summary:") + logging.info(f" Followers : {len(followers)}") + logging.info(f" Following : {len(following)}") + logging.info(f" To follow back : {len(not_followed_back)}") + + # --- Dry run preview --- + if args.dry_run: + logging.info("πŸ” Dry run β€” no follows will be made. Accounts to follow back:") + for handle in not_followed_back: + print(f" β†’ @{handle}") + return + + # --- Follow back --- + follow_back(client, followers, following) + + logging.info("πŸŽ‰ All done!") + + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/bsky/remove_duplicates.py b/bsky/remove_duplicates.py new file mode 100644 index 0000000..f85f4b3 --- /dev/null +++ b/bsky/remove_duplicates.py @@ -0,0 +1,56 @@ +import pandas as pd +import os + +# ── Config ──────────────────────────────────────────────────────────────────── +INPUT_FILE = 'users_to_keep.csv' +OUTPUT_FILE = 'users_to_keep_clean.csv' +# ────────────────────────────────────────────────────────────────────────────── + + +def load_csv(filepath: str) -> pd.DataFrame: + """Load CSV file and return a DataFrame.""" + if not os.path.exists(filepath): + raise FileNotFoundError(f"File not found: {filepath}") + return pd.read_csv(filepath) + + +def remove_duplicates(df: pd.DataFrame) -> pd.DataFrame: + """Normalize handles and remove duplicate rows.""" + df['Handle'] = df['Handle'].str.strip().str.lower() + return df.drop_duplicates(subset='Handle', keep='first') + + +def save_csv(df: pd.DataFrame, filepath: str) -> None: + """Save DataFrame to CSV.""" + df.to_csv(filepath, index=False) + + +def print_report(before: int, after: int, output: str) -> None: + """Print a summary report.""" + removed = before - after + print("─" * 40) + print(f" Rows before : {before}") + print(f" Rows after : {after}") + print(f" Removed : {removed}") + print(f" Saved to : {output}") + print("─" * 40) + + +def main(): + print(f"\nπŸ“‚ Loading '{INPUT_FILE}'...") + df = load_csv(INPUT_FILE) + before = len(df) + + print("πŸ” Removing duplicates...") + df_clean = remove_duplicates(df) + after = len(df_clean) + + print(f"πŸ’Ύ Saving to '{OUTPUT_FILE}'...") + save_csv(df_clean, OUTPUT_FILE) + + print_report(before, after, OUTPUT_FILE) + print("βœ… Done!\n") + + +if __name__ == '__main__': + main() \ No newline at end of file diff --git a/bsky/unfollow.py b/bsky/unfollow.py new file mode 100644 index 0000000..36771ef --- /dev/null +++ b/bsky/unfollow.py @@ -0,0 +1,294 @@ +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() diff --git a/bsky/users_to_keep.py b/bsky/users_to_keep.py new file mode 100644 index 0000000..c763bef --- /dev/null +++ b/bsky/users_to_keep.py @@ -0,0 +1,184 @@ +import argparse +import logging +import sys +import csv +import time +import re +from atproto import Client + +# --- Logging --- +logging.basicConfig( + format="%(asctime)s [%(levelname)s] %(message)s", + level=logging.INFO, + stream=sys.stdout +) + + +def login(email: str, password: str) -> Client: + """Login to Bluesky using email and app password.""" + client = Client() + try: + logging.info(f"Attempting login with email: {email}") + client.login(email, password) + logging.info("βœ… Login successful!") + return client + except Exception as e: + logging.error(f"❌ Login failed: {e}") + sys.exit(1) + + +def get_my_handle(client) -> str: + """Retrieve the authenticated user's handle.""" + return client.me.handle + + +def get_current_follows(client, handle: str) -> dict: + """Fetches the accounts the user is currently following. Returns {handle: follow_uri}.""" + logging.info("πŸ“‹ Fetching your current follows...") + follows = [] + cursor = None + while True: + try: + params = {'actor': handle, 'cursor': cursor, 'limit': 100} + res = client.app.bsky.graph.get_follows(params) + follows.extend(res.follows) + if not res.cursor: + break + cursor = res.cursor + time.sleep(0.3) + except Exception as e: + logging.warning(f"⚠️ Error fetching follows: {e}") + break + + # Build {handle: viewer.following URI} so we can delete the follow record + result = {} + for user in follows: + follow_uri = None + if user.viewer and user.viewer.following: + follow_uri = user.viewer.following + result[user.handle] = { + "did": user.did, + "follow_uri": follow_uri + } + + logging.info(f"Found {len(result)} accounts you currently follow.") + return result + + +def read_target_handles(filename: str) -> list: + """Reads the CSV file and returns a list of handles to follow.""" + handles = [] + with open(filename, mode='r', newline='', encoding='utf-8') as file: + reader = csv.DictReader(file) + for row in reader: + handle = row['Handle'].strip().lower() + if handle: + handles.append(handle) + logging.info(f"πŸ“„ Loaded {len(handles)} handles from '{filename}'.") + return handles + + +def resolve_handle_to_did(client, handle: str) -> str | None: + """Resolves a handle to a DID via the AT Protocol.""" + try: + res = client.com.atproto.identity.resolve_handle({'handle': handle}) + return res.did + except Exception as e: + logging.warning(f"⚠️ Could not resolve handle @{handle}: {e}") + return None + + +def unfollow_all(client, current_follows: dict) -> None: + """Unfollows everyone the user currently follows.""" + logging.info(f"πŸ” Unfollowing {len(current_follows)} accounts...") + unfollow_count = 0 + fail_count = 0 + + for handle, data in current_follows.items(): + follow_uri = data.get("follow_uri") + if not follow_uri: + logging.warning(f"⚠️ No follow URI for @{handle}, skipping.") + continue + try: + # follow_uri format: at://did:.../app.bsky.graph.follow/rkey + parts = follow_uri.split("/") + rkey = parts[-1] + repo = parts[2] + client.com.atproto.repo.delete_record({ + "repo": repo, + "collection": "app.bsky.graph.follow", + "rkey": rkey + }) + logging.info(f" βž– Unfollowed: @{handle}") + unfollow_count += 1 + time.sleep(0.5) + except Exception as e: + logging.error(f" ❌ Failed to unfollow @{handle}: {e}") + fail_count += 1 + + logging.info(f"βœ… Unfollowed {unfollow_count} accounts. ({fail_count} failed)") + + +def follow_targets(client, target_handles: list) -> None: + """Follows each handle in the target list.""" + logging.info(f"βž• Following {len(target_handles)} accounts from CSV...") + follow_count = 0 + fail_count = 0 + + for handle in target_handles: + did = resolve_handle_to_did(client, handle) + if not did: + fail_count += 1 + continue + 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: @{handle}") + follow_count += 1 + time.sleep(0.5) + except Exception as e: + logging.error(f" ❌ Failed to follow @{handle}: {e}") + fail_count += 1 + + logging.info(f"βœ… Followed {follow_count} accounts. ({fail_count} failed)") + + +def main(): + parser = argparse.ArgumentParser( + description="Unfollow everyone on Bluesky, then follow users from a CSV list." + ) + parser.add_argument("bsky_email", help="Bluesky login email (e.g., you@email.com)") + parser.add_argument("bsky_app_password", help="Bluesky app password") + parser.add_argument("--input_csv", required=True, help="CSV file with 'Handle' column") + parser.add_argument("--skip_unfollow", action="store_true", help="Skip the unfollow step") + args = parser.parse_args() + + # --- Login --- + client = login(args.bsky_email, args.bsky_app_password) + my_handle = get_my_handle(client) + logging.info(f"πŸ‘€ Logged in as: @{my_handle}") + + # --- Read target CSV --- + target_handles = read_target_handles(args.input_csv) + + # --- Step 1: Unfollow everyone --- + if not args.skip_unfollow: + current_follows = get_current_follows(client, my_handle) + unfollow_all(client, current_follows) + else: + logging.info("⏭️ Skipping unfollow step (--skip_unfollow flag set).") + + # --- Step 2: Follow CSV list --- + follow_targets(client, target_handles) + + logging.info("πŸŽ‰ All done!") + + +if __name__ == "__main__": + main() \ No newline at end of file