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