Files
scripts/bsky/unfollow_non_unfollowers.py
Guillem Hernandez Sola febf938c6b Some scripts
2026-04-11 15:27:24 +02:00

196 lines
7.8 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 DATA
# ──────────────────────────────────────────────────────────────────────────────
def get_all_followers(client, handle: str) -> set:
"""Returns a set of DIDs of everyone who follows you."""
logging.info("📋 Fetching your followers...")
followers = set()
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.add(user.did)
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:
"""Returns {did: user_object} of everyone you follow."""
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
# ──────────────────────────────────────────────────────────────────────────────
# DISPLAY
# ──────────────────────────────────────────────────────────────────────────────
def print_user_list(users: dict):
"""Prints a numbered list of users to unfollow."""
print(f"\n📤 YOU FOLLOW — THEY DON'T FOLLOW BACK ({len(users)} total):")
print("-" * 60)
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)
# ──────────────────────────────────────────────────────────────────────────────
# UNFOLLOW
# ──────────────────────────────────────────────────────────────────────────────
def unfollow_non_followers(client, to_unfollow: dict, dry_run: bool):
"""Prints the list, prompts for confirmation, then unfollows."""
if not to_unfollow:
logging.info("✅ Everyone you follow also follows you back!")
return
# Always print the full list first
print_user_list(to_unfollow)
if dry_run:
logging.info("🔍 Dry run — no changes made.")
return
# Confirmation prompt
answer = input(f"\n❓ Unfollow these {len(to_unfollow)} accounts? (y/N): ").strip().lower()
if answer != 'y':
logging.info("❌ Cancelled — 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 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)
print(f" Unfollowed : {success}")
print(f" ❌ Failed : {failed}")
print("=" * 60)
# ──────────────────────────────────────────────────────────────────────────────
# MAIN
# ──────────────────────────────────────────────────────────────────────────────
def main():
parser = argparse.ArgumentParser(
description="Unfollow everyone on Bluesky who doesn't follow you 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="Print the list without unfollowing anyone"
)
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 both lists ---
followers = get_all_followers(client, my_handle)
following = get_all_following(client, my_handle)
# --- Compute who doesn't follow back ---
to_unfollow = {
did: user
for did, user in following.items()
if did not in followers
}
# --- Summary ---
print("\n" + "=" * 60)
print("📊 SUMMARY")
print("=" * 60)
print(f" ➡️ Total following : {len(following)}")
print(f" 👥 Total followers : {len(followers)}")
print(f" 📤 Don't follow you back : {len(to_unfollow)}")
print("=" * 60)
# --- Unfollow ---
unfollow_non_followers(client, to_unfollow, dry_run=args.dry_run)
logging.info("🎉 All done!")
if __name__ == "__main__":
main()