159 lines
5.0 KiB
Python
159 lines
5.0 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
|
||
)
|
||
|
||
|
||
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() |