Added all
This commit is contained in:
452
bsky/compare_follows.py
Normal file
452
bsky/compare_follows.py
Normal file
@@ -0,0 +1,452 @@
|
|||||||
|
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()
|
||||||
264
bsky/remove_english_followers.py
Normal file
264
bsky/remove_english_followers.py
Normal file
@@ -0,0 +1,264 @@
|
|||||||
|
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:
|
||||||
|
"""Login using email or handle + app password."""
|
||||||
|
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 FOLLOWING
|
||||||
|
# ──────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def get_all_following(client, handle: str) -> list:
|
||||||
|
"""Fetches ALL accounts you are following."""
|
||||||
|
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)
|
||||||
|
following.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
|
||||||
|
logging.info(f"➡️ You are following {len(following)} accounts.")
|
||||||
|
return following
|
||||||
|
|
||||||
|
|
||||||
|
# ──────────────────────────────────────────────────────────────────────────────
|
||||||
|
# LANGUAGE DETECTION
|
||||||
|
# ──────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
# Languages that protect a user from removal
|
||||||
|
PROTECTED_LANGS = {'ca', 'es'} # Catalan and Spanish
|
||||||
|
|
||||||
|
# Target language to remove
|
||||||
|
TARGET_LANGS = {'en'}
|
||||||
|
|
||||||
|
PROTECTED_BIO_KEYWORDS = [
|
||||||
|
# Catalan
|
||||||
|
'català', 'catala', 'catalunya', 'ca-es', 'catalan', 'valencian',
|
||||||
|
'valencià', '📍 cat',
|
||||||
|
# Spanish
|
||||||
|
'español', 'castellano', 'españa', 'es-es', 'spanish', '🇪🇸',
|
||||||
|
]
|
||||||
|
|
||||||
|
ENGLISH_BIO_KEYWORDS = [
|
||||||
|
'english', 'en-us', 'en-gb', '🇬🇧', '🇺🇸', '🇦🇺', '🇨🇦',
|
||||||
|
'united kingdom', 'united states', 'australia', 'new zealand',
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def get_user_post_languages(client, did: str, sample_size: int) -> set:
|
||||||
|
"""
|
||||||
|
Fetches recent posts for a user and returns all language codes found.
|
||||||
|
Normalizes tags: 'en-US', 'en-GB' → 'en'
|
||||||
|
"""
|
||||||
|
langs_found = set()
|
||||||
|
try:
|
||||||
|
params = {'actor': did, 'limit': sample_size}
|
||||||
|
res = client.app.bsky.feed.get_author_feed(params)
|
||||||
|
for item in res.feed:
|
||||||
|
record = item.post.record
|
||||||
|
if hasattr(record, 'langs') and record.langs:
|
||||||
|
for lang in record.langs:
|
||||||
|
langs_found.add(lang.split('-')[0].lower())
|
||||||
|
except Exception as e:
|
||||||
|
logging.warning(f"⚠️ Could not fetch posts for {did}: {e}")
|
||||||
|
return langs_found
|
||||||
|
|
||||||
|
|
||||||
|
def is_english_only(client, user, sample_size: int) -> bool:
|
||||||
|
"""
|
||||||
|
Returns True if the user's default language appears to be English
|
||||||
|
and NOT any protected language (Catalan, Spanish).
|
||||||
|
|
||||||
|
Detection logic:
|
||||||
|
1. Fetch recent post language tags:
|
||||||
|
- Any protected lang (ca, es) found → keep (return False)
|
||||||
|
- Only English tags found → remove (return True)
|
||||||
|
- No tags found → fallback to bio
|
||||||
|
|
||||||
|
2. Bio keyword fallback:
|
||||||
|
- Protected keywords found → keep
|
||||||
|
- English keywords found → remove
|
||||||
|
- No signal → keep (safe default)
|
||||||
|
"""
|
||||||
|
post_langs = get_user_post_languages(client, user.did, sample_size)
|
||||||
|
|
||||||
|
if post_langs:
|
||||||
|
has_protected = bool(post_langs & PROTECTED_LANGS)
|
||||||
|
has_english = bool(post_langs & TARGET_LANGS)
|
||||||
|
|
||||||
|
if has_protected:
|
||||||
|
return False # Protected language detected → keep
|
||||||
|
if has_english:
|
||||||
|
return True # English with no protected lang → remove
|
||||||
|
|
||||||
|
# Fallback: bio keyword scan
|
||||||
|
bio = (user.description or "").lower()
|
||||||
|
|
||||||
|
if any(kw in bio for kw in PROTECTED_BIO_KEYWORDS):
|
||||||
|
return False # Protected keyword in bio → keep
|
||||||
|
if any(kw in bio for kw in ENGLISH_BIO_KEYWORDS):
|
||||||
|
return True # English keyword in bio → remove
|
||||||
|
|
||||||
|
return False # No signal → keep (safe default)
|
||||||
|
|
||||||
|
|
||||||
|
# ──────────────────────────────────────────────────────────────────────────────
|
||||||
|
# UNFOLLOW ENGLISH USERS
|
||||||
|
# ──────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def unfollow_english_users(client, following: list, dry_run: bool, sample_size: int):
|
||||||
|
"""
|
||||||
|
Iterates through the accounts you follow, detects English-only speakers,
|
||||||
|
and unfollows them after confirmation.
|
||||||
|
"""
|
||||||
|
logging.info(f"🔍 Analyzing {len(following)} accounts you follow for English-only signal...")
|
||||||
|
logging.info(f" Checking last {sample_size} posts per user — this may take a while...")
|
||||||
|
|
||||||
|
english_users = []
|
||||||
|
|
||||||
|
for i, user in enumerate(following):
|
||||||
|
if i > 0 and i % 25 == 0:
|
||||||
|
logging.info(f" Progress: {i}/{len(following)} checked | Found so far: {len(english_users)}")
|
||||||
|
|
||||||
|
if is_english_only(client, user, sample_size):
|
||||||
|
english_users.append(user)
|
||||||
|
logging.debug(f" 🏴 English-only detected: @{user.handle}")
|
||||||
|
|
||||||
|
time.sleep(0.4) # Rate limit protection
|
||||||
|
|
||||||
|
# --- Summary ---
|
||||||
|
print("\n" + "=" * 60)
|
||||||
|
print(f"📊 ANALYSIS COMPLETE")
|
||||||
|
print(f" Total following checked : {len(following)}")
|
||||||
|
print(f" English-only detected : {len(english_users)}")
|
||||||
|
print(f" Will be kept : {len(following) - len(english_users)}")
|
||||||
|
print("=" * 60)
|
||||||
|
|
||||||
|
if not english_users:
|
||||||
|
logging.info("✅ No English-only accounts found in your following list.")
|
||||||
|
return
|
||||||
|
|
||||||
|
# --- Preview list ---
|
||||||
|
print(f"\n🇬🇧 ENGLISH-ONLY ACCOUNTS TO UNFOLLOW ({len(english_users)} total):")
|
||||||
|
print("-" * 60)
|
||||||
|
for u in english_users:
|
||||||
|
display = f" ({u.display_name})" if u.display_name else ""
|
||||||
|
print(f" - @{u.handle}{display}")
|
||||||
|
print("-" * 60)
|
||||||
|
|
||||||
|
if dry_run:
|
||||||
|
logging.info("🔍 Dry run mode — no changes made.")
|
||||||
|
return
|
||||||
|
|
||||||
|
confirm = input(f"\nUnfollow these {len(english_users)} accounts? (y/N): ")
|
||||||
|
if confirm.lower() != 'y':
|
||||||
|
logging.info("❌ Cancelled. No one was unfollowed.")
|
||||||
|
return
|
||||||
|
|
||||||
|
# --- Unfollow using the follow record URI ---
|
||||||
|
logging.info("➖ Unfollowing English-only accounts...")
|
||||||
|
success = 0
|
||||||
|
failed = 0
|
||||||
|
|
||||||
|
for user in english_users:
|
||||||
|
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)
|
||||||
|
logging.info(f"✅ Done! Unfollowed {success} accounts. ({failed} failed)")
|
||||||
|
print("=" * 60)
|
||||||
|
|
||||||
|
|
||||||
|
# ──────────────────────────────────────────────────────────────────────────────
|
||||||
|
# MAIN
|
||||||
|
# ──────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def main():
|
||||||
|
parser = argparse.ArgumentParser(
|
||||||
|
description="Unfollow Bluesky accounts whose default language is English."
|
||||||
|
)
|
||||||
|
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="Preview English-only accounts without unfollowing them"
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--sample_size",
|
||||||
|
type=int,
|
||||||
|
default=25,
|
||||||
|
help="Number of recent posts to check per user for language detection (default: 25)"
|
||||||
|
)
|
||||||
|
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 following ---
|
||||||
|
following = get_all_following(client, my_handle)
|
||||||
|
|
||||||
|
# --- Detect & unfollow ---
|
||||||
|
unfollow_english_users(
|
||||||
|
client,
|
||||||
|
following,
|
||||||
|
dry_run=args.dry_run,
|
||||||
|
sample_size=args.sample_size
|
||||||
|
)
|
||||||
|
|
||||||
|
logging.info("🎉 All done!")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
476
bsky/remove_spanish_followers.py
Normal file
476
bsky/remove_spanish_followers.py
Normal file
@@ -0,0 +1,476 @@
|
|||||||
|
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
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ──────────────────────────────────────────────────────────────────────────────
|
||||||
|
# AUTH
|
||||||
|
# ──────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def login(client: Client, identifier: str, password: str):
|
||||||
|
"""Login using email or handle + app password."""
|
||||||
|
try:
|
||||||
|
logging.info(f"Attempting login: {identifier}")
|
||||||
|
client.login(identifier, password)
|
||||||
|
logging.info("✅ Login successful!")
|
||||||
|
except Exception as e:
|
||||||
|
logging.error(f"❌ Login failed: {e}")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
|
||||||
|
# ──────────────────────────────────────────────────────────────────────────────
|
||||||
|
# FETCH DATA
|
||||||
|
# ──────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def get_all_followers(client, handle: str) -> list:
|
||||||
|
"""Fetches ALL accounts that follow you."""
|
||||||
|
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
|
||||||
|
logging.info(f"👥 Found {len(followers)} followers.")
|
||||||
|
return followers
|
||||||
|
|
||||||
|
|
||||||
|
def get_all_following(client, handle: str) -> list:
|
||||||
|
"""Fetches the complete list of users you are following."""
|
||||||
|
logging.info("📋 Fetching your following list...")
|
||||||
|
following = []
|
||||||
|
cursor = None
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
params = {'actor': handle, 'cursor': cursor, 'limit': 100}
|
||||||
|
res = client.app.bsky.graph.get_follows(params)
|
||||||
|
following.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 list: {e}")
|
||||||
|
break
|
||||||
|
logging.info(f"➡️ You follow {len(following)} accounts.")
|
||||||
|
return following
|
||||||
|
|
||||||
|
|
||||||
|
def get_all_interactions(client, handle):
|
||||||
|
"""
|
||||||
|
Fetches all historical interactions.
|
||||||
|
Returns:
|
||||||
|
- interacted_users: dict of everyone you interacted with
|
||||||
|
- fans: dict of users who liked or reposted YOUR posts
|
||||||
|
"""
|
||||||
|
interacted_users = {}
|
||||||
|
fans = {}
|
||||||
|
|
||||||
|
# 1. Likes (posts YOU liked)
|
||||||
|
logging.info("Fetching all historical likes...")
|
||||||
|
cursor = None
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
params = {'actor': handle, 'cursor': cursor, 'limit': 100}
|
||||||
|
res = client.app.bsky.feed.get_actor_likes(params)
|
||||||
|
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. 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}
|
||||||
|
res = client.app.bsky.feed.get_author_feed(params)
|
||||||
|
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. Notifications (mentions, replies, likes, reposts TO YOU)
|
||||||
|
logging.info("Fetching all historical notifications...")
|
||||||
|
cursor = None
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
params = {'cursor': cursor, 'limit': 100}
|
||||||
|
res = client.app.bsky.notification.list_notifications(params)
|
||||||
|
for notif in res.notifications:
|
||||||
|
if notif.reason in ['mention', 'reply', 'quote', 'like', 'repost']:
|
||||||
|
interacted_users[notif.author.did] = notif.author.handle
|
||||||
|
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
|
||||||
|
interacted_users.pop(client.me.did, None)
|
||||||
|
fans.pop(client.me.did, None)
|
||||||
|
|
||||||
|
logging.info(f"🔁 Found {len(interacted_users)} unique interacted users.")
|
||||||
|
logging.info(f"❤️ Found {len(fans)} unique fans (liked/reposted your posts).")
|
||||||
|
return interacted_users, fans
|
||||||
|
|
||||||
|
|
||||||
|
# ──────────────────────────────────────────────────────────────────────────────
|
||||||
|
# LANGUAGE DETECTION
|
||||||
|
# ──────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
# Bluesky stores user content language preferences in their profile preferences.
|
||||||
|
# The most reliable signal is the language tags on their recent posts.
|
||||||
|
# We check BOTH recent post langs AND profile bio keywords as a fallback.
|
||||||
|
|
||||||
|
SPANISH_POST_LANGS = {'es'}
|
||||||
|
CATALAN_POST_LANGS = {'ca'}
|
||||||
|
|
||||||
|
SPANISH_BIO_KEYWORDS = [
|
||||||
|
'español', 'castellano', 'españa', 'es-es', 'spanish',
|
||||||
|
'🇪🇸', 'madrid', 'barcelona', 'sevilla', 'valencia',
|
||||||
|
]
|
||||||
|
CATALAN_BIO_KEYWORDS = [
|
||||||
|
'català', 'catala', 'catalunya', 'ca-es', 'catalan',
|
||||||
|
'📍 cat', 'valencian', 'valencià',
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def get_user_post_languages(client, did: str, sample_size: int = 25) -> set:
|
||||||
|
"""
|
||||||
|
Fetches recent posts for a user and returns the set of language codes found.
|
||||||
|
e.g. {'es'}, {'ca', 'es'}, {'en'}, set()
|
||||||
|
"""
|
||||||
|
langs_found = set()
|
||||||
|
try:
|
||||||
|
params = {'actor': did, 'limit': sample_size}
|
||||||
|
res = client.app.bsky.feed.get_author_feed(params)
|
||||||
|
for item in res.feed:
|
||||||
|
record = item.post.record
|
||||||
|
if hasattr(record, 'langs') and record.langs:
|
||||||
|
for lang in record.langs:
|
||||||
|
# Normalize: 'es-419', 'es-ES' → 'es'
|
||||||
|
langs_found.add(lang.split('-')[0].lower())
|
||||||
|
except Exception as e:
|
||||||
|
logging.warning(f"⚠️ Could not fetch posts for {did}: {e}")
|
||||||
|
return langs_found
|
||||||
|
|
||||||
|
|
||||||
|
def is_spanish_only(client, user, sample_size: int = 25) -> bool:
|
||||||
|
"""
|
||||||
|
Returns True if the user's DEFAULT/primary language appears to be Spanish
|
||||||
|
and NOT Catalan. Logic:
|
||||||
|
|
||||||
|
1. Check recent post language tags:
|
||||||
|
- If they post in 'ca' (Catalan) → keep them (return False)
|
||||||
|
- If ALL detected langs are 'es' → Spanish only (return True)
|
||||||
|
|
||||||
|
2. Fallback to bio keywords if no post langs found.
|
||||||
|
"""
|
||||||
|
post_langs = get_user_post_languages(client, user.did, sample_size)
|
||||||
|
|
||||||
|
if post_langs:
|
||||||
|
has_catalan = bool(post_langs & CATALAN_POST_LANGS)
|
||||||
|
has_spanish = bool(post_langs & SPANISH_POST_LANGS)
|
||||||
|
|
||||||
|
if has_catalan:
|
||||||
|
return False # Catalan speaker → keep
|
||||||
|
if has_spanish and not has_catalan:
|
||||||
|
return True # Spanish only → remove
|
||||||
|
|
||||||
|
# Fallback: bio keyword check
|
||||||
|
bio = (user.description or "").lower()
|
||||||
|
has_catalan_bio = any(kw in bio for kw in CATALAN_BIO_KEYWORDS)
|
||||||
|
has_spanish_bio = any(kw in bio for kw in SPANISH_BIO_KEYWORDS)
|
||||||
|
|
||||||
|
if has_catalan_bio:
|
||||||
|
return False
|
||||||
|
if has_spanish_bio:
|
||||||
|
return True
|
||||||
|
|
||||||
|
return False # No signal → keep (safe default)
|
||||||
|
|
||||||
|
|
||||||
|
# ──────────────────────────────────────────────────────────────────────────────
|
||||||
|
# REMOVE SPANISH FOLLOWERS
|
||||||
|
# ──────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def remove_spanish_followers(client, followers: list, dry_run: bool = False):
|
||||||
|
"""
|
||||||
|
Iterates through followers, detects Spanish-only speakers,
|
||||||
|
and removes them (blocks then unblocks = soft remove from followers).
|
||||||
|
Shows a confirmation prompt before acting.
|
||||||
|
"""
|
||||||
|
logging.info("🔍 Analyzing followers for Spanish-only language signal...")
|
||||||
|
logging.info(" (This may take a while — fetching recent posts per user)")
|
||||||
|
|
||||||
|
spanish_followers = []
|
||||||
|
|
||||||
|
for i, user in enumerate(followers):
|
||||||
|
if i > 0 and i % 25 == 0:
|
||||||
|
logging.info(f" Processed {i}/{len(followers)} followers...")
|
||||||
|
|
||||||
|
if is_spanish_only(client, user):
|
||||||
|
spanish_followers.append(user)
|
||||||
|
|
||||||
|
time.sleep(0.4) # Rate limit protection
|
||||||
|
|
||||||
|
if not spanish_followers:
|
||||||
|
logging.info("✅ No Spanish-only followers found.")
|
||||||
|
return
|
||||||
|
|
||||||
|
# --- Preview ---
|
||||||
|
print("\n" + "=" * 55)
|
||||||
|
print(f"🇪🇸 SPANISH-ONLY FOLLOWERS TO REMOVE ({len(spanish_followers)} total)")
|
||||||
|
print("=" * 55)
|
||||||
|
for u in spanish_followers:
|
||||||
|
display = u.display_name or ""
|
||||||
|
print(f" - @{u.handle} {display}")
|
||||||
|
print("=" * 55)
|
||||||
|
|
||||||
|
if dry_run:
|
||||||
|
logging.info("🔍 Dry run — no changes made.")
|
||||||
|
return
|
||||||
|
|
||||||
|
confirm = input(f"\nRemove these {len(spanish_followers)} followers? (y/N): ")
|
||||||
|
if confirm.lower() != 'y':
|
||||||
|
logging.info("❌ Cancelled. No followers removed.")
|
||||||
|
return
|
||||||
|
|
||||||
|
# --- Remove: block → unblock (removes them from your followers) ---
|
||||||
|
logging.info("🗑️ Removing followers...")
|
||||||
|
success = 0
|
||||||
|
failed = 0
|
||||||
|
|
||||||
|
for user in spanish_followers:
|
||||||
|
try:
|
||||||
|
# Block creates a record; we then delete it — net effect: they no longer follow you
|
||||||
|
block = client.app.bsky.graph.block.create(
|
||||||
|
repo=client.me.did,
|
||||||
|
record={
|
||||||
|
"$type": "app.bsky.graph.block",
|
||||||
|
"subject": user.did,
|
||||||
|
"createdAt": client.get_current_time_iso()
|
||||||
|
}
|
||||||
|
)
|
||||||
|
time.sleep(0.3)
|
||||||
|
# Unblock immediately — follower relationship is severed
|
||||||
|
parts = block.uri.split("/")
|
||||||
|
client.com.atproto.repo.delete_record({
|
||||||
|
"repo": client.me.did,
|
||||||
|
"collection": "app.bsky.graph.block",
|
||||||
|
"rkey": parts[-1]
|
||||||
|
})
|
||||||
|
logging.info(f" ✅ Removed follower: @{user.handle}")
|
||||||
|
success += 1
|
||||||
|
time.sleep(0.5)
|
||||||
|
except Exception as e:
|
||||||
|
logging.error(f" ❌ Failed to remove @{user.handle}: {e}")
|
||||||
|
failed += 1
|
||||||
|
|
||||||
|
logging.info(f"✅ Removed {success} followers. ({failed} failed)")
|
||||||
|
|
||||||
|
|
||||||
|
# ──────────────────────────────────────────────────────────────────────────────
|
||||||
|
# ORIGINAL HELPERS (unchanged)
|
||||||
|
# ──────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
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"💾 Saved 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 liked/reposted your posts!")
|
||||||
|
return
|
||||||
|
|
||||||
|
print("\n" + "=" * 50)
|
||||||
|
print(f"💙 USERS WHO LIKED/REPOSTED YOU ({len(to_follow)} total)")
|
||||||
|
print("=" * 50)
|
||||||
|
for did, handle in to_follow:
|
||||||
|
print(f" - @{handle}")
|
||||||
|
print("=" * 50)
|
||||||
|
|
||||||
|
confirm = input(f"\nFollow these {len(to_follow)} users? (y/N): ")
|
||||||
|
if confirm.lower() != 'y':
|
||||||
|
logging.info("Skipping following new fans.")
|
||||||
|
return
|
||||||
|
|
||||||
|
success = 0
|
||||||
|
for did, handle in to_follow:
|
||||||
|
try:
|
||||||
|
client.follow(did)
|
||||||
|
logging.info(f" ✅ Followed: @{handle}")
|
||||||
|
success += 1
|
||||||
|
time.sleep(0.5)
|
||||||
|
except Exception as e:
|
||||||
|
logging.error(f" ❌ Failed to follow @{handle}: {e}")
|
||||||
|
logging.info(f"✅ Followed {success} 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 interacted-but-not-followed list to '{output_csv}'")
|
||||||
|
|
||||||
|
|
||||||
|
def has_language_in_profile(user):
|
||||||
|
"""Checks if the user 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']
|
||||||
|
return any(kw in desc for kw in ca_keywords + es_keywords)
|
||||||
|
|
||||||
|
|
||||||
|
def posts_in_target_languages(client, did):
|
||||||
|
"""Returns True if the user recently posted in Catalan or Spanish."""
|
||||||
|
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 posts for {did}: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def clean_users(client, following, interacted_users):
|
||||||
|
"""Unfollows users that don't match language/interaction criteria."""
|
||||||
|
users_to_unfollow = []
|
||||||
|
logging.info("🔍 Analyzing following list...")
|
||||||
|
|
||||||
|
for i, user in enumerate(following):
|
||||||
|
if i > 0 and i % 50 == 0:
|
||||||
|
logging.info(f" Processed {i}/{len(following)} users...")
|
||||||
|
if user.did in interacted_users:
|
||||||
|
continue
|
||||||
|
if has_language_in_profile(user):
|
||||||
|
continue
|
||||||
|
if posts_in_target_languages(client, user.did):
|
||||||
|
continue
|
||||||
|
users_to_unfollow.append(user)
|
||||||
|
|
||||||
|
if not users_to_unfollow:
|
||||||
|
logging.info("✅ No users to unfollow based on your criteria.")
|
||||||
|
return
|
||||||
|
|
||||||
|
print("\n" + "=" * 50)
|
||||||
|
print(f"🛑 USERS TO UNFOLLOW ({len(users_to_unfollow)} total)")
|
||||||
|
print("=" * 50)
|
||||||
|
for u in users_to_unfollow:
|
||||||
|
print(f" - @{u.handle} ({u.display_name or ''})")
|
||||||
|
print("=" * 50)
|
||||||
|
|
||||||
|
confirm = input(f"\nUnfollow these {len(users_to_unfollow)} users? (y/N): ")
|
||||||
|
if confirm.lower() != 'y':
|
||||||
|
logging.info("Cancelled.")
|
||||||
|
return
|
||||||
|
|
||||||
|
success = 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 += 1
|
||||||
|
time.sleep(0.5)
|
||||||
|
else:
|
||||||
|
logging.warning(f" ⚠️ No follow record for @{user.handle}")
|
||||||
|
except Exception as e:
|
||||||
|
logging.error(f" ❌ Failed to unfollow @{user.handle}: {e}")
|
||||||
|
logging.info(f"✅ Unfollowed {success} users.")
|
||||||
|
|
||||||
|
|
||||||
|
# ──────────────────────────────────────────────────────────────────────────────
|
||||||
|
# MAIN
|
||||||
|
# ──────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def main():
|
||||||
|
parser = argparse.ArgumentParser(description="Clean up your Bluesky account.")
|
||||||
|
parser.add_argument("bsky_username", help="Bluesky email or handle (e.g., you@email.com)")
|
||||||
|
parser.add_argument("bsky_app_password", help="Bluesky app password")
|
||||||
|
parser.add_argument("--output_csv", default="following_list.csv", help="Output CSV for following list")
|
||||||
|
parser.add_argument("--missed_csv", default="interacted_not_followed.csv", help="Output CSV for interacted-but-not-followed")
|
||||||
|
parser.add_argument("--remove_spanish", action="store_true", help="Remove followers whose default language is Spanish (not Catalan)")
|
||||||
|
parser.add_argument("--dry_run", action="store_true", help="Preview Spanish followers without removing them")
|
||||||
|
parser.add_argument("--sample_size", type=int, default=25, help="Number of recent posts to check per user for language detection (default: 25)")
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
# --- Login ---
|
||||||
|
client = Client()
|
||||||
|
login(client, args.bsky_username, args.bsky_app_password)
|
||||||
|
my_handle = client.me.handle
|
||||||
|
logging.info(f"👤 Logged in as: @{my_handle}")
|
||||||
|
|
||||||
|
# --- Remove Spanish-only followers (new feature) ---
|
||||||
|
if args.remove_spanish:
|
||||||
|
followers = get_all_followers(client, my_handle)
|
||||||
|
remove_spanish_followers(client, followers, dry_run=args.dry_run)
|
||||||
|
return # Exit after this task — run separately from the clean_users flow
|
||||||
|
|
||||||
|
# --- Original flow ---
|
||||||
|
following = get_all_following(client, my_handle)
|
||||||
|
download_following_list(following, args.output_csv)
|
||||||
|
|
||||||
|
interacted_users, fans = get_all_interactions(client, my_handle)
|
||||||
|
follow_new_fans(client, fans)
|
||||||
|
list_interacted_but_not_followed(interacted_users, following, args.missed_csv)
|
||||||
|
clean_users(client, following, interacted_users)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
Reference in New Issue
Block a user