bsky mgm
This commit is contained in:
159
bsky/followback.py
Normal file
159
bsky/followback.py
Normal file
@@ -0,0 +1,159 @@
|
||||
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()
|
||||
56
bsky/remove_duplicates.py
Normal file
56
bsky/remove_duplicates.py
Normal file
@@ -0,0 +1,56 @@
|
||||
import pandas as pd
|
||||
import os
|
||||
|
||||
# ── Config ────────────────────────────────────────────────────────────────────
|
||||
INPUT_FILE = 'users_to_keep.csv'
|
||||
OUTPUT_FILE = 'users_to_keep_clean.csv'
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def load_csv(filepath: str) -> pd.DataFrame:
|
||||
"""Load CSV file and return a DataFrame."""
|
||||
if not os.path.exists(filepath):
|
||||
raise FileNotFoundError(f"File not found: {filepath}")
|
||||
return pd.read_csv(filepath)
|
||||
|
||||
|
||||
def remove_duplicates(df: pd.DataFrame) -> pd.DataFrame:
|
||||
"""Normalize handles and remove duplicate rows."""
|
||||
df['Handle'] = df['Handle'].str.strip().str.lower()
|
||||
return df.drop_duplicates(subset='Handle', keep='first')
|
||||
|
||||
|
||||
def save_csv(df: pd.DataFrame, filepath: str) -> None:
|
||||
"""Save DataFrame to CSV."""
|
||||
df.to_csv(filepath, index=False)
|
||||
|
||||
|
||||
def print_report(before: int, after: int, output: str) -> None:
|
||||
"""Print a summary report."""
|
||||
removed = before - after
|
||||
print("─" * 40)
|
||||
print(f" Rows before : {before}")
|
||||
print(f" Rows after : {after}")
|
||||
print(f" Removed : {removed}")
|
||||
print(f" Saved to : {output}")
|
||||
print("─" * 40)
|
||||
|
||||
|
||||
def main():
|
||||
print(f"\n📂 Loading '{INPUT_FILE}'...")
|
||||
df = load_csv(INPUT_FILE)
|
||||
before = len(df)
|
||||
|
||||
print("🔍 Removing duplicates...")
|
||||
df_clean = remove_duplicates(df)
|
||||
after = len(df_clean)
|
||||
|
||||
print(f"💾 Saving to '{OUTPUT_FILE}'...")
|
||||
save_csv(df_clean, OUTPUT_FILE)
|
||||
|
||||
print_report(before, after, OUTPUT_FILE)
|
||||
print("✅ Done!\n")
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
294
bsky/unfollow.py
Normal file
294
bsky/unfollow.py
Normal file
@@ -0,0 +1,294 @@
|
||||
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
|
||||
)
|
||||
|
||||
def get_all_interactions(client, handle):
|
||||
"""
|
||||
Fetches all historical interactions.
|
||||
Returns:
|
||||
- interacted_users: dict of everyone you interacted with (to protect from unfollow)
|
||||
- fans: dict of users who specifically liked or reposted YOUR posts (to potentially follow)
|
||||
"""
|
||||
interacted_users = {}
|
||||
fans = {}
|
||||
|
||||
# 1. Fetch Likes (Posts YOU liked)
|
||||
logging.info("Fetching all historical likes...")
|
||||
cursor = None
|
||||
while True:
|
||||
try:
|
||||
params = {'actor': handle, 'cursor': cursor, 'limit': 100} # Adjusted to include parameters
|
||||
res = client.app.bsky.feed.get_actor_likes(params) # Corrected to pass params as a dictionary
|
||||
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. Fetch 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} # Adjusted to include parameters
|
||||
res = client.app.bsky.feed.get_author_feed(params) # Corrected to pass params as a dictionary
|
||||
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. Fetch Notifications (Mentions, Replies, Likes, Reposts TO YOU)
|
||||
logging.info("Fetching all historical notifications (likes, reposts, mentions)...")
|
||||
cursor = None
|
||||
while True:
|
||||
try:
|
||||
params = {'cursor': cursor, 'limit': 100} # Adjusted to include parameters
|
||||
res = client.app.bsky.notification.list_notifications(params) # Corrected to pass params as a dictionary
|
||||
for notif in res.notifications:
|
||||
# Add to general interactions (protects from unfollow)
|
||||
if notif.reason in ['mention', 'reply', 'quote', 'like', 'repost']:
|
||||
interacted_users[notif.author.did] = notif.author.handle
|
||||
|
||||
# Specifically track people who liked or reposted your posts
|
||||
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 from the lists if present
|
||||
if client.me.did in interacted_users:
|
||||
del interacted_users[client.me.did]
|
||||
if client.me.did in fans:
|
||||
del fans[client.me.did]
|
||||
|
||||
logging.info(f"Found {len(interacted_users)} unique users you have interacted with.")
|
||||
logging.info(f"Found {len(fans)} unique users who have liked or reposted your posts.")
|
||||
return interacted_users, fans
|
||||
|
||||
def get_all_following(client, handle):
|
||||
"""Fetches the complete list of users you are following."""
|
||||
logging.info("Fetching your complete following list...")
|
||||
following = []
|
||||
cursor = None
|
||||
while True:
|
||||
try:
|
||||
params = {'actor': handle, 'cursor': cursor, 'limit': 100} # Adjusted to include parameters
|
||||
res = client.app.bsky.graph.get_follows(params) # Corrected to pass params as a dictionary
|
||||
following.extend(res.follows)
|
||||
if not res.cursor:
|
||||
break
|
||||
cursor = res.cursor
|
||||
except Exception as e:
|
||||
logging.warning(f"Error fetching following list: {e}")
|
||||
break
|
||||
return following
|
||||
|
||||
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"Downloaded 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 has liked or reposted your posts!")
|
||||
return
|
||||
|
||||
print("\n" + "="*50)
|
||||
print(f"💙 USERS WHO LIKED/REPOSTED YOU (TO FOLLOW) ({len(to_follow)} total) 💙")
|
||||
print("="*50)
|
||||
for did, handle in to_follow:
|
||||
print(f"- @{handle}")
|
||||
print("="*50)
|
||||
|
||||
confirm = input(f"\nDo you want to FOLLOW these {len(to_follow)} users? (y/N): ")
|
||||
if confirm.lower() != 'y':
|
||||
logging.info("Skipping following new fans.")
|
||||
return
|
||||
|
||||
logging.info("Proceeding to follow users...")
|
||||
success_count = 0
|
||||
for did, handle in to_follow:
|
||||
try:
|
||||
client.follow(did)
|
||||
logging.info(f"✅ Followed: @{handle}")
|
||||
success_count += 1
|
||||
time.sleep(0.5) # Rate limit protection
|
||||
except Exception as e:
|
||||
logging.error(f"❌ Failed to follow @{handle}: {e}")
|
||||
|
||||
logging.info(f"Finished! Successfully followed {success_count} 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 the full list of interacted-but-not-followed users to {output_csv}")
|
||||
|
||||
def has_language_in_profile(user):
|
||||
"""Checks if the user explicitly 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']
|
||||
|
||||
for kw in ca_keywords + es_keywords:
|
||||
if kw in desc:
|
||||
return True
|
||||
return False
|
||||
|
||||
def posts_in_target_languages(client, did):
|
||||
"""
|
||||
Fetches the user's recent posts and checks the internal Bluesky language tags.
|
||||
Returns True if they recently posted in Catalan ('ca') or Spanish ('es').
|
||||
"""
|
||||
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 recent posts for {did} to check language: {e}")
|
||||
|
||||
return False
|
||||
|
||||
def clean_users(client, following, interacted_users):
|
||||
users_to_unfollow = []
|
||||
|
||||
logging.info("Analyzing following list against your criteria (this may take a moment to check recent posts)...")
|
||||
|
||||
for i, user in enumerate(following):
|
||||
if i > 0 and i % 50 == 0:
|
||||
logging.info(f"Processed {i}/{len(following)} users...")
|
||||
|
||||
# 1. Keep if interacted with (This now INCLUDES people who liked/reposted your posts)
|
||||
if user.did in interacted_users:
|
||||
continue
|
||||
|
||||
# 2. Keep if language is in profile bio
|
||||
if has_language_in_profile(user):
|
||||
continue
|
||||
|
||||
# 3. Keep if they post/interact in Catalan or Spanish
|
||||
if posts_in_target_languages(client, user.did):
|
||||
continue
|
||||
|
||||
# If none of the above match, add to unfollow list
|
||||
users_to_unfollow.append(user)
|
||||
|
||||
if not users_to_unfollow:
|
||||
logging.info("No users found to unfollow based on your criteria. You're all clean!")
|
||||
return
|
||||
|
||||
# --- HUMAN APPROVAL STEP ---
|
||||
print("\n" + "="*50)
|
||||
print(f"🛑 USERS TO UNFOLLOW ({len(users_to_unfollow)} total) 🛑")
|
||||
print("="*50)
|
||||
for u in users_to_unfollow:
|
||||
display = u.display_name or ""
|
||||
print(f"- @{u.handle} ({display})")
|
||||
print("="*50)
|
||||
|
||||
confirm = input(f"\nDo you want to proceed and UNFOLLOW these {len(users_to_unfollow)} users? (y/N): ")
|
||||
|
||||
if confirm.lower() != 'y':
|
||||
logging.info("Operation cancelled by user. No one was unfollowed.")
|
||||
return
|
||||
|
||||
logging.info("Proceeding with unfollows...")
|
||||
success_count = 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_count += 1
|
||||
time.sleep(0.5) # Rate limit protection
|
||||
else:
|
||||
logging.warning(f"⚠️ Could not find follow record for @{user.handle}")
|
||||
except Exception as e:
|
||||
logging.error(f"❌ Failed to unfollow @{user.handle}: {e}")
|
||||
|
||||
logging.info(f"Finished! Successfully unfollowed {success_count} users.")
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="Clean up your Bsky users.")
|
||||
parser.add_argument("bsky_username", help="Bluesky username (e.g., user.bsky.social)")
|
||||
parser.add_argument("bsky_app_password", help="Bluesky app password")
|
||||
parser.add_argument("--output_csv", default="following_list.csv", help="Output CSV file name for following list")
|
||||
parser.add_argument("--missed_csv", default="interacted_not_followed.csv", help="Output CSV for users you interacted with but don't follow")
|
||||
args = parser.parse_args()
|
||||
|
||||
# --- Login ---
|
||||
client = Client()
|
||||
try:
|
||||
logging.info(f"Attempting login with user: {args.bsky_username}")
|
||||
client.login(args.bsky_username, args.bsky_app_password)
|
||||
logging.info("Login successful!")
|
||||
except Exception as e:
|
||||
logging.error(f"Login failed: {e}")
|
||||
sys.exit(1)
|
||||
|
||||
# 1. Get all following
|
||||
following = get_all_following(client, args.bsky_username)
|
||||
download_following_list(following, args.output_csv)
|
||||
|
||||
# 2. Get all historical interactions AND fans
|
||||
interacted_users, fans = get_all_interactions(client, args.bsky_username)
|
||||
|
||||
# 3. Follow users who liked/reposted your posts
|
||||
follow_new_fans(client, fans)
|
||||
|
||||
# 4. Save the full list of interacted-but-not-followed users to CSV
|
||||
list_interacted_but_not_followed(interacted_users, following, args.missed_csv)
|
||||
|
||||
# 5. Filter and ask for human approval before unfollowing
|
||||
clean_users(client, following, interacted_users)
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
184
bsky/users_to_keep.py
Normal file
184
bsky/users_to_keep.py
Normal file
@@ -0,0 +1,184 @@
|
||||
import argparse
|
||||
import logging
|
||||
import sys
|
||||
import csv
|
||||
import time
|
||||
import re
|
||||
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_current_follows(client, handle: str) -> dict:
|
||||
"""Fetches the accounts the user is currently following. Returns {handle: follow_uri}."""
|
||||
logging.info("📋 Fetching your current follows...")
|
||||
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 follows: {e}")
|
||||
break
|
||||
|
||||
# Build {handle: viewer.following URI} so we can delete the follow record
|
||||
result = {}
|
||||
for user in follows:
|
||||
follow_uri = None
|
||||
if user.viewer and user.viewer.following:
|
||||
follow_uri = user.viewer.following
|
||||
result[user.handle] = {
|
||||
"did": user.did,
|
||||
"follow_uri": follow_uri
|
||||
}
|
||||
|
||||
logging.info(f"Found {len(result)} accounts you currently follow.")
|
||||
return result
|
||||
|
||||
|
||||
def read_target_handles(filename: str) -> list:
|
||||
"""Reads the CSV file and returns a list of handles to follow."""
|
||||
handles = []
|
||||
with open(filename, mode='r', newline='', encoding='utf-8') as file:
|
||||
reader = csv.DictReader(file)
|
||||
for row in reader:
|
||||
handle = row['Handle'].strip().lower()
|
||||
if handle:
|
||||
handles.append(handle)
|
||||
logging.info(f"📄 Loaded {len(handles)} handles from '{filename}'.")
|
||||
return handles
|
||||
|
||||
|
||||
def resolve_handle_to_did(client, handle: str) -> str | None:
|
||||
"""Resolves a handle to a DID via the AT Protocol."""
|
||||
try:
|
||||
res = client.com.atproto.identity.resolve_handle({'handle': handle})
|
||||
return res.did
|
||||
except Exception as e:
|
||||
logging.warning(f"⚠️ Could not resolve handle @{handle}: {e}")
|
||||
return None
|
||||
|
||||
|
||||
def unfollow_all(client, current_follows: dict) -> None:
|
||||
"""Unfollows everyone the user currently follows."""
|
||||
logging.info(f"🔁 Unfollowing {len(current_follows)} accounts...")
|
||||
unfollow_count = 0
|
||||
fail_count = 0
|
||||
|
||||
for handle, data in current_follows.items():
|
||||
follow_uri = data.get("follow_uri")
|
||||
if not follow_uri:
|
||||
logging.warning(f"⚠️ No follow URI for @{handle}, skipping.")
|
||||
continue
|
||||
try:
|
||||
# follow_uri format: at://did:.../app.bsky.graph.follow/rkey
|
||||
parts = follow_uri.split("/")
|
||||
rkey = parts[-1]
|
||||
repo = parts[2]
|
||||
client.com.atproto.repo.delete_record({
|
||||
"repo": repo,
|
||||
"collection": "app.bsky.graph.follow",
|
||||
"rkey": rkey
|
||||
})
|
||||
logging.info(f" ➖ Unfollowed: @{handle}")
|
||||
unfollow_count += 1
|
||||
time.sleep(0.5)
|
||||
except Exception as e:
|
||||
logging.error(f" ❌ Failed to unfollow @{handle}: {e}")
|
||||
fail_count += 1
|
||||
|
||||
logging.info(f"✅ Unfollowed {unfollow_count} accounts. ({fail_count} failed)")
|
||||
|
||||
|
||||
def follow_targets(client, target_handles: list) -> None:
|
||||
"""Follows each handle in the target list."""
|
||||
logging.info(f"➕ Following {len(target_handles)} accounts from CSV...")
|
||||
follow_count = 0
|
||||
fail_count = 0
|
||||
|
||||
for handle in target_handles:
|
||||
did = resolve_handle_to_did(client, handle)
|
||||
if not did:
|
||||
fail_count += 1
|
||||
continue
|
||||
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: @{handle}")
|
||||
follow_count += 1
|
||||
time.sleep(0.5)
|
||||
except Exception as e:
|
||||
logging.error(f" ❌ Failed to follow @{handle}: {e}")
|
||||
fail_count += 1
|
||||
|
||||
logging.info(f"✅ Followed {follow_count} accounts. ({fail_count} failed)")
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Unfollow everyone on Bluesky, then follow users from a CSV list."
|
||||
)
|
||||
parser.add_argument("bsky_email", help="Bluesky login email (e.g., you@email.com)")
|
||||
parser.add_argument("bsky_app_password", help="Bluesky app password")
|
||||
parser.add_argument("--input_csv", required=True, help="CSV file with 'Handle' column")
|
||||
parser.add_argument("--skip_unfollow", action="store_true", help="Skip the unfollow step")
|
||||
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}")
|
||||
|
||||
# --- Read target CSV ---
|
||||
target_handles = read_target_handles(args.input_csv)
|
||||
|
||||
# --- Step 1: Unfollow everyone ---
|
||||
if not args.skip_unfollow:
|
||||
current_follows = get_current_follows(client, my_handle)
|
||||
unfollow_all(client, current_follows)
|
||||
else:
|
||||
logging.info("⏭️ Skipping unfollow step (--skip_unfollow flag set).")
|
||||
|
||||
# --- Step 2: Follow CSV list ---
|
||||
follow_targets(client, target_handles)
|
||||
|
||||
logging.info("🎉 All done!")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user