diff --git a/bsky/compare_follows.py b/bsky/compare_follows.py new file mode 100644 index 0000000..3719a72 --- /dev/null +++ b/bsky/compare_follows.py @@ -0,0 +1,452 @@ +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() \ No newline at end of file diff --git a/bsky/remove_english_followers.py b/bsky/remove_english_followers.py new file mode 100644 index 0000000..b05e2eb --- /dev/null +++ b/bsky/remove_english_followers.py @@ -0,0 +1,264 @@ +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: + """Login using email or handle + app password.""" + 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 FOLLOWING +# ────────────────────────────────────────────────────────────────────────────── + +def get_all_following(client, handle: str) -> list: + """Fetches ALL accounts you are following.""" + 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) + following.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 + logging.info(f"➡️ You are following {len(following)} accounts.") + return following + + +# ────────────────────────────────────────────────────────────────────────────── +# LANGUAGE DETECTION +# ────────────────────────────────────────────────────────────────────────────── + +# Languages that protect a user from removal +PROTECTED_LANGS = {'ca', 'es'} # Catalan and Spanish + +# Target language to remove +TARGET_LANGS = {'en'} + +PROTECTED_BIO_KEYWORDS = [ + # Catalan + 'català', 'catala', 'catalunya', 'ca-es', 'catalan', 'valencian', + 'valencià', '📍 cat', + # Spanish + 'español', 'castellano', 'españa', 'es-es', 'spanish', '🇪🇸', +] + +ENGLISH_BIO_KEYWORDS = [ + 'english', 'en-us', 'en-gb', '🇬🇧', '🇺🇸', '🇦🇺', '🇨🇦', + 'united kingdom', 'united states', 'australia', 'new zealand', +] + + +def get_user_post_languages(client, did: str, sample_size: int) -> set: + """ + Fetches recent posts for a user and returns all language codes found. + Normalizes tags: 'en-US', 'en-GB' → 'en' + """ + langs_found = set() + try: + params = {'actor': did, 'limit': sample_size} + res = client.app.bsky.feed.get_author_feed(params) + for item in res.feed: + record = item.post.record + if hasattr(record, 'langs') and record.langs: + for lang in record.langs: + langs_found.add(lang.split('-')[0].lower()) + except Exception as e: + logging.warning(f"⚠️ Could not fetch posts for {did}: {e}") + return langs_found + + +def is_english_only(client, user, sample_size: int) -> bool: + """ + Returns True if the user's default language appears to be English + and NOT any protected language (Catalan, Spanish). + + Detection logic: + 1. Fetch recent post language tags: + - Any protected lang (ca, es) found → keep (return False) + - Only English tags found → remove (return True) + - No tags found → fallback to bio + + 2. Bio keyword fallback: + - Protected keywords found → keep + - English keywords found → remove + - No signal → keep (safe default) + """ + post_langs = get_user_post_languages(client, user.did, sample_size) + + if post_langs: + has_protected = bool(post_langs & PROTECTED_LANGS) + has_english = bool(post_langs & TARGET_LANGS) + + if has_protected: + return False # Protected language detected → keep + if has_english: + return True # English with no protected lang → remove + + # Fallback: bio keyword scan + bio = (user.description or "").lower() + + if any(kw in bio for kw in PROTECTED_BIO_KEYWORDS): + return False # Protected keyword in bio → keep + if any(kw in bio for kw in ENGLISH_BIO_KEYWORDS): + return True # English keyword in bio → remove + + return False # No signal → keep (safe default) + + +# ────────────────────────────────────────────────────────────────────────────── +# UNFOLLOW ENGLISH USERS +# ────────────────────────────────────────────────────────────────────────────── + +def unfollow_english_users(client, following: list, dry_run: bool, sample_size: int): + """ + Iterates through the accounts you follow, detects English-only speakers, + and unfollows them after confirmation. + """ + logging.info(f"🔍 Analyzing {len(following)} accounts you follow for English-only signal...") + logging.info(f" Checking last {sample_size} posts per user — this may take a while...") + + english_users = [] + + for i, user in enumerate(following): + if i > 0 and i % 25 == 0: + logging.info(f" Progress: {i}/{len(following)} checked | Found so far: {len(english_users)}") + + if is_english_only(client, user, sample_size): + english_users.append(user) + logging.debug(f" 🏴󠁧󠁢󠁥󠁮󠁧󠁿 English-only detected: @{user.handle}") + + time.sleep(0.4) # Rate limit protection + + # --- Summary --- + print("\n" + "=" * 60) + print(f"📊 ANALYSIS COMPLETE") + print(f" Total following checked : {len(following)}") + print(f" English-only detected : {len(english_users)}") + print(f" Will be kept : {len(following) - len(english_users)}") + print("=" * 60) + + if not english_users: + logging.info("✅ No English-only accounts found in your following list.") + return + + # --- Preview list --- + print(f"\n🇬🇧 ENGLISH-ONLY ACCOUNTS TO UNFOLLOW ({len(english_users)} total):") + print("-" * 60) + for u in english_users: + display = f" ({u.display_name})" if u.display_name else "" + print(f" - @{u.handle}{display}") + print("-" * 60) + + if dry_run: + logging.info("🔍 Dry run mode — no changes made.") + return + + confirm = input(f"\nUnfollow these {len(english_users)} accounts? (y/N): ") + if confirm.lower() != 'y': + logging.info("❌ Cancelled. No one was unfollowed.") + return + + # --- Unfollow using the follow record URI --- + logging.info("➖ Unfollowing English-only accounts...") + success = 0 + failed = 0 + + for user in english_users: + 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) + logging.info(f"✅ Done! Unfollowed {success} accounts. ({failed} failed)") + print("=" * 60) + + +# ────────────────────────────────────────────────────────────────────────────── +# MAIN +# ────────────────────────────────────────────────────────────────────────────── + +def main(): + parser = argparse.ArgumentParser( + description="Unfollow Bluesky accounts whose default language is English." + ) + 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="Preview English-only accounts without unfollowing them" + ) + parser.add_argument( + "--sample_size", + type=int, + default=25, + help="Number of recent posts to check per user for language detection (default: 25)" + ) + 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 following --- + following = get_all_following(client, my_handle) + + # --- Detect & unfollow --- + unfollow_english_users( + client, + following, + dry_run=args.dry_run, + sample_size=args.sample_size + ) + + logging.info("🎉 All done!") + + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/bsky/remove_spanish_followers.py b/bsky/remove_spanish_followers.py new file mode 100644 index 0000000..7bc087b --- /dev/null +++ b/bsky/remove_spanish_followers.py @@ -0,0 +1,476 @@ +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 +) + + +# ────────────────────────────────────────────────────────────────────────────── +# AUTH +# ────────────────────────────────────────────────────────────────────────────── + +def login(client: Client, identifier: str, password: str): + """Login using email or handle + app password.""" + try: + logging.info(f"Attempting login: {identifier}") + client.login(identifier, password) + logging.info("✅ Login successful!") + except Exception as e: + logging.error(f"❌ Login failed: {e}") + sys.exit(1) + + +# ────────────────────────────────────────────────────────────────────────────── +# FETCH DATA +# ────────────────────────────────────────────────────────────────────────────── + +def get_all_followers(client, handle: str) -> list: + """Fetches ALL accounts that follow you.""" + 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 + logging.info(f"👥 Found {len(followers)} followers.") + return followers + + +def get_all_following(client, handle: str) -> list: + """Fetches the complete list of users you are following.""" + logging.info("📋 Fetching your following list...") + following = [] + cursor = None + while True: + try: + params = {'actor': handle, 'cursor': cursor, 'limit': 100} + res = client.app.bsky.graph.get_follows(params) + following.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 list: {e}") + break + logging.info(f"➡️ You follow {len(following)} accounts.") + return following + + +def get_all_interactions(client, handle): + """ + Fetches all historical interactions. + Returns: + - interacted_users: dict of everyone you interacted with + - fans: dict of users who liked or reposted YOUR posts + """ + interacted_users = {} + fans = {} + + # 1. Likes (posts YOU liked) + logging.info("Fetching all historical likes...") + cursor = None + while True: + try: + params = {'actor': handle, 'cursor': cursor, 'limit': 100} + res = client.app.bsky.feed.get_actor_likes(params) + 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. 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} + res = client.app.bsky.feed.get_author_feed(params) + 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. Notifications (mentions, replies, likes, reposts TO YOU) + logging.info("Fetching all historical notifications...") + cursor = None + while True: + try: + params = {'cursor': cursor, 'limit': 100} + res = client.app.bsky.notification.list_notifications(params) + for notif in res.notifications: + if notif.reason in ['mention', 'reply', 'quote', 'like', 'repost']: + interacted_users[notif.author.did] = notif.author.handle + 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 + interacted_users.pop(client.me.did, None) + fans.pop(client.me.did, None) + + logging.info(f"🔁 Found {len(interacted_users)} unique interacted users.") + logging.info(f"❤️ Found {len(fans)} unique fans (liked/reposted your posts).") + return interacted_users, fans + + +# ────────────────────────────────────────────────────────────────────────────── +# LANGUAGE DETECTION +# ────────────────────────────────────────────────────────────────────────────── + +# Bluesky stores user content language preferences in their profile preferences. +# The most reliable signal is the language tags on their recent posts. +# We check BOTH recent post langs AND profile bio keywords as a fallback. + +SPANISH_POST_LANGS = {'es'} +CATALAN_POST_LANGS = {'ca'} + +SPANISH_BIO_KEYWORDS = [ + 'español', 'castellano', 'españa', 'es-es', 'spanish', + '🇪🇸', 'madrid', 'barcelona', 'sevilla', 'valencia', +] +CATALAN_BIO_KEYWORDS = [ + 'català', 'catala', 'catalunya', 'ca-es', 'catalan', + '📍 cat', 'valencian', 'valencià', +] + + +def get_user_post_languages(client, did: str, sample_size: int = 25) -> set: + """ + Fetches recent posts for a user and returns the set of language codes found. + e.g. {'es'}, {'ca', 'es'}, {'en'}, set() + """ + langs_found = set() + try: + params = {'actor': did, 'limit': sample_size} + res = client.app.bsky.feed.get_author_feed(params) + for item in res.feed: + record = item.post.record + if hasattr(record, 'langs') and record.langs: + for lang in record.langs: + # Normalize: 'es-419', 'es-ES' → 'es' + langs_found.add(lang.split('-')[0].lower()) + except Exception as e: + logging.warning(f"⚠️ Could not fetch posts for {did}: {e}") + return langs_found + + +def is_spanish_only(client, user, sample_size: int = 25) -> bool: + """ + Returns True if the user's DEFAULT/primary language appears to be Spanish + and NOT Catalan. Logic: + + 1. Check recent post language tags: + - If they post in 'ca' (Catalan) → keep them (return False) + - If ALL detected langs are 'es' → Spanish only (return True) + + 2. Fallback to bio keywords if no post langs found. + """ + post_langs = get_user_post_languages(client, user.did, sample_size) + + if post_langs: + has_catalan = bool(post_langs & CATALAN_POST_LANGS) + has_spanish = bool(post_langs & SPANISH_POST_LANGS) + + if has_catalan: + return False # Catalan speaker → keep + if has_spanish and not has_catalan: + return True # Spanish only → remove + + # Fallback: bio keyword check + bio = (user.description or "").lower() + has_catalan_bio = any(kw in bio for kw in CATALAN_BIO_KEYWORDS) + has_spanish_bio = any(kw in bio for kw in SPANISH_BIO_KEYWORDS) + + if has_catalan_bio: + return False + if has_spanish_bio: + return True + + return False # No signal → keep (safe default) + + +# ────────────────────────────────────────────────────────────────────────────── +# REMOVE SPANISH FOLLOWERS +# ────────────────────────────────────────────────────────────────────────────── + +def remove_spanish_followers(client, followers: list, dry_run: bool = False): + """ + Iterates through followers, detects Spanish-only speakers, + and removes them (blocks then unblocks = soft remove from followers). + Shows a confirmation prompt before acting. + """ + logging.info("🔍 Analyzing followers for Spanish-only language signal...") + logging.info(" (This may take a while — fetching recent posts per user)") + + spanish_followers = [] + + for i, user in enumerate(followers): + if i > 0 and i % 25 == 0: + logging.info(f" Processed {i}/{len(followers)} followers...") + + if is_spanish_only(client, user): + spanish_followers.append(user) + + time.sleep(0.4) # Rate limit protection + + if not spanish_followers: + logging.info("✅ No Spanish-only followers found.") + return + + # --- Preview --- + print("\n" + "=" * 55) + print(f"🇪🇸 SPANISH-ONLY FOLLOWERS TO REMOVE ({len(spanish_followers)} total)") + print("=" * 55) + for u in spanish_followers: + display = u.display_name or "" + print(f" - @{u.handle} {display}") + print("=" * 55) + + if dry_run: + logging.info("🔍 Dry run — no changes made.") + return + + confirm = input(f"\nRemove these {len(spanish_followers)} followers? (y/N): ") + if confirm.lower() != 'y': + logging.info("❌ Cancelled. No followers removed.") + return + + # --- Remove: block → unblock (removes them from your followers) --- + logging.info("🗑️ Removing followers...") + success = 0 + failed = 0 + + for user in spanish_followers: + try: + # Block creates a record; we then delete it — net effect: they no longer follow you + block = client.app.bsky.graph.block.create( + repo=client.me.did, + record={ + "$type": "app.bsky.graph.block", + "subject": user.did, + "createdAt": client.get_current_time_iso() + } + ) + time.sleep(0.3) + # Unblock immediately — follower relationship is severed + parts = block.uri.split("/") + client.com.atproto.repo.delete_record({ + "repo": client.me.did, + "collection": "app.bsky.graph.block", + "rkey": parts[-1] + }) + logging.info(f" ✅ Removed follower: @{user.handle}") + success += 1 + time.sleep(0.5) + except Exception as e: + logging.error(f" ❌ Failed to remove @{user.handle}: {e}") + failed += 1 + + logging.info(f"✅ Removed {success} followers. ({failed} failed)") + + +# ────────────────────────────────────────────────────────────────────────────── +# ORIGINAL HELPERS (unchanged) +# ────────────────────────────────────────────────────────────────────────────── + +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"💾 Saved 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 liked/reposted your posts!") + return + + print("\n" + "=" * 50) + print(f"💙 USERS WHO LIKED/REPOSTED YOU ({len(to_follow)} total)") + print("=" * 50) + for did, handle in to_follow: + print(f" - @{handle}") + print("=" * 50) + + confirm = input(f"\nFollow these {len(to_follow)} users? (y/N): ") + if confirm.lower() != 'y': + logging.info("Skipping following new fans.") + return + + success = 0 + for did, handle in to_follow: + try: + client.follow(did) + logging.info(f" ✅ Followed: @{handle}") + success += 1 + time.sleep(0.5) + except Exception as e: + logging.error(f" ❌ Failed to follow @{handle}: {e}") + logging.info(f"✅ Followed {success} 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 interacted-but-not-followed list to '{output_csv}'") + + +def has_language_in_profile(user): + """Checks if the user 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'] + return any(kw in desc for kw in ca_keywords + es_keywords) + + +def posts_in_target_languages(client, did): + """Returns True if the user recently posted in Catalan or Spanish.""" + 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 posts for {did}: {e}") + return False + + +def clean_users(client, following, interacted_users): + """Unfollows users that don't match language/interaction criteria.""" + users_to_unfollow = [] + logging.info("🔍 Analyzing following list...") + + for i, user in enumerate(following): + if i > 0 and i % 50 == 0: + logging.info(f" Processed {i}/{len(following)} users...") + if user.did in interacted_users: + continue + if has_language_in_profile(user): + continue + if posts_in_target_languages(client, user.did): + continue + users_to_unfollow.append(user) + + if not users_to_unfollow: + logging.info("✅ No users to unfollow based on your criteria.") + return + + print("\n" + "=" * 50) + print(f"🛑 USERS TO UNFOLLOW ({len(users_to_unfollow)} total)") + print("=" * 50) + for u in users_to_unfollow: + print(f" - @{u.handle} ({u.display_name or ''})") + print("=" * 50) + + confirm = input(f"\nUnfollow these {len(users_to_unfollow)} users? (y/N): ") + if confirm.lower() != 'y': + logging.info("Cancelled.") + return + + success = 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 += 1 + time.sleep(0.5) + else: + logging.warning(f" ⚠️ No follow record for @{user.handle}") + except Exception as e: + logging.error(f" ❌ Failed to unfollow @{user.handle}: {e}") + logging.info(f"✅ Unfollowed {success} users.") + + +# ────────────────────────────────────────────────────────────────────────────── +# MAIN +# ────────────────────────────────────────────────────────────────────────────── + +def main(): + parser = argparse.ArgumentParser(description="Clean up your Bluesky account.") + parser.add_argument("bsky_username", help="Bluesky email or handle (e.g., you@email.com)") + parser.add_argument("bsky_app_password", help="Bluesky app password") + parser.add_argument("--output_csv", default="following_list.csv", help="Output CSV for following list") + parser.add_argument("--missed_csv", default="interacted_not_followed.csv", help="Output CSV for interacted-but-not-followed") + parser.add_argument("--remove_spanish", action="store_true", help="Remove followers whose default language is Spanish (not Catalan)") + parser.add_argument("--dry_run", action="store_true", help="Preview Spanish followers without removing them") + parser.add_argument("--sample_size", type=int, default=25, help="Number of recent posts to check per user for language detection (default: 25)") + args = parser.parse_args() + + # --- Login --- + client = Client() + login(client, args.bsky_username, args.bsky_app_password) + my_handle = client.me.handle + logging.info(f"👤 Logged in as: @{my_handle}") + + # --- Remove Spanish-only followers (new feature) --- + if args.remove_spanish: + followers = get_all_followers(client, my_handle) + remove_spanish_followers(client, followers, dry_run=args.dry_run) + return # Exit after this task — run separately from the clean_users flow + + # --- Original flow --- + following = get_all_following(client, my_handle) + download_following_list(following, args.output_csv) + + interacted_users, fans = get_all_interactions(client, my_handle) + follow_new_fans(client, fans) + list_interacted_but_not_followed(interacted_users, following, args.missed_csv) + clean_users(client, following, interacted_users) + + +if __name__ == "__main__": + main() \ No newline at end of file