Files
scripts/bsky/compare_follows.py
Guillem Hernandez Sola 1acb9955f2 Added all
2026-04-11 15:14:42 +02:00

452 lines
19 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 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()