Skip to content

Instantly share code, notes, and snippets.

@0xa
Last active July 27, 2022 13:28
Show Gist options
  • Star 4 You must be signed in to star a gist
  • Fork 1 You must be signed in to fork a gist
  • Save 0xa/b4e289102c6e87b58ad9f72c2aa93400 to your computer and use it in GitHub Desktop.
Save 0xa/b4e289102c6e87b58ad9f72c2aa93400 to your computer and use it in GitHub Desktop.
Mastodon followings management
#!/usr/bin/env python3
"""
A script that go through your followings and unfollows dead accounts.
It notices empty accounts, accounts that were deleted locally and remotely,
and also cleans up dead instances if allowed to.
It has a cache so you can run it once without --unfollow to preview its
actions, and a second time that will skip all verified active profiles.
With colors and a nice progress bar with item count, %, and ETA.
Requirements:
pip3 install Mastodon.py colorama tqdm python-dateutil --user
It requires a settings.py file:
CLIENT_KEY = ''
CLIENT_SECRET = ''
ACCESS_TOKEN = ''
API_BASE = 'https://mastodon.social'
# Cache file, None to disable
CACHE_FILE = './last_toot_cache.pickle'
# Instances that are confirmed down forever
ASSUME_DEAD_INSTANCES = {'dead.example.com'}
# Instances that should be skipped
# (ie where most accounts would often appear empty)
SKIP_INSTANCES = {'ephemeral.glitch.social'}
"""
from argparse import ArgumentParser, RawDescriptionHelpFormatter
from datetime import timedelta, datetime, timezone
import os
import pickle
import shutil
from mastodon import Mastodon
from tqdm import tqdm
import requests
import dateutil
import dateutil.parser
from colorama import Fore, Style, init as colorama_init
import settings
HTTP_TIMEOUT = 6
def cprint(c, s):
print(c + s + Fore.WHITE + Style.NORMAL)
def parse_time_ago(v):
v = v.lower().strip()
units = {
'y': timedelta(days=365),
'm': timedelta(days=30),
'd': timedelta(days=1),
}
return int(v[:-1]) * units[v[-1]]
class Error(Exception):
pass
class UserGone(Exception):
pass
def parse_date(d):
return dateutil.parser.parse(d)
def get_last_toot(s, url):
if settings.CACHE_FILE:
try:
with open(settings.CACHE_FILE, 'rb') as f:
cache = pickle.load(f)
except Exception as e:
cprint(Fore.RED, "Error loading cache: {}".format(e))
cache = {}
else:
cache = {}
if url in cache:
return cache[url]
headers = {'Accept': 'application/json'}
r = s.get(url, headers=headers, timeout=HTTP_TIMEOUT)
if r.status_code == 404:
raise UserGone("Profile not found")
if r.status_code == 410:
raise UserGone("Profile deleted")
if r.status_code != 200:
raise Error("Unexpected profile return code: {}".format(r.status_code))
try:
j = r.json()
toots_url = j['outbox']
except Exception as e:
raise Error("Profile error ({!r})".format(str(e)))
r = s.get(toots_url, headers=headers, timeout=HTTP_TIMEOUT)
if r.status_code != 200:
raise Error("Unexpected toots return code: {}".format(r.status_code))
try:
j = r.json()
items = j.get('orderedItems', [])
if len(items) == 0:
raise UserGone("No toot found")
except UserGone:
raise
except Exception as e:
raise Error("Toots error ({!r})".format(str(e)))
result = min(parse_date(t.get('published')) for t in items)
if settings.CACHE_FILE:
cache[url] = result
try:
if os.path.isfile(settings.CACHE_FILE):
shutil.copyfile(settings.CACHE_FILE, settings.CACHE_FILE + '.prev')
with open(settings.CACHE_FILE, 'wb') as f:
cache = pickle.dump(cache, f)
except Exception as e:
cprint(Fore.RED, "Error saving cache: {!r}".format(e))
return result
def main():
colorama_init()
parser = ArgumentParser(description=__doc__,
formatter_class=RawDescriptionHelpFormatter)
parser.add_argument('--min-activity', dest='min_activity',
type=parse_time_ago, default="1y",
help=("Remove followings inactive for a given period"
" (m for months, y for years, d for days)"))
parser.add_argument('--target-count', dest='target_count', type=int,
help=("Target some following count (will try to stop"
" when you have that many followings left)"))
parser.add_argument('--unfollow', action='store_true',
help="Actually unfollow")
parser.add_argument('-v', '--verbose', action='store_true',
help="Display more things")
args = parser.parse_args()
session = requests.Session()
mastodon = Mastodon(
client_id=settings.CLIENT_KEY,
client_secret=settings.CLIENT_SECRET,
access_token=settings.ACCESS_TOKEN,
api_base_url=settings.API_BASE,
)
current_user = mastodon.account_verify_credentials()
uid = current_user['id']
followings_count = current_user['following_count']
local_count = followings_count
goal_msg = ""
if args.target_count:
goal_msg = "(goal: n>={})".format(args.target_count)
now = datetime.now(tz=timezone.utc)
def clog(c, s):
tqdm.write(c + s + Fore.WHITE + Style.NORMAL)
cprint(Fore.GREEN, "Current user: @{} (#{})".format(current_user['username'], uid))
cprint(Fore.GREEN, "Followings: {} {}".format(followings_count, goal_msg))
if args.unfollow:
cprint(Fore.RED, "Action: unfollow")
else:
cprint(Fore.YELLOW, "Action: none")
followings = mastodon.account_following(uid)
followings = mastodon.fetch_remaining(followings)
bar = tqdm(list(followings))
for f in bar:
fid = f.get('id')
acct = f.get('acct')
fullhandle = "@{}".format(acct)
if '@' in acct:
inst = acct.split('@', 1)[1].lower()
else:
inst = None
if args.target_count is not None and local_count <= args.target_count:
clog(Fore.RED + Style.BRIGHT,
"{} followings left; stopping".format(local_count))
break
title_printed = False
def title():
nonlocal title_printed
if title_printed:
return
title_printed = True
clog(Fore.WHITE + Style.BRIGHT,
"Account: {} (#{})".format(f.get('acct'), fid))
if args.verbose:
title()
try:
bar.set_description(fullhandle.ljust(30, ' '))
act = False
if args.min_activity and inst not in settings.SKIP_INSTANCES:
try:
last_toot = get_last_toot(session, f.get('url'))
if last_toot < now - args.min_activity:
act = True
msg = "(!)"
title()
clog(Fore.WHITE, "- Last toot: {} {}".format(last_toot, msg))
else:
msg = "(pass)"
if args.verbose:
clog(Fore.WHITE, "- Last toot: {} {}".format(last_toot, msg))
except UserGone as e:
moved = f.get('moved')
if moved:
# TODO: follow new account and unfollow old
act = False
title()
clog(Fore.YELLOW,
"- User moved ({}) [NOT IMPLEMENTED]".format(moved))
else:
act = True
title()
clog(Fore.YELLOW, "- User gone ({})".format(e))
except (Error, requests.RequestException) as e:
if inst and inst in settings.ASSUME_DEAD_INSTANCES:
act = True
title()
clog(Fore.YELLOW, "- Instance gone ({})".format(e))
else:
raise
if act:
local_count -= 1
if args.unfollow:
clog(Fore.GREEN + Style.BRIGHT,
"- Unfollowing {}".format(fullhandle))
mastodon.account_unfollow(fid)
else:
clog(Fore.GREEN + Style.BRIGHT,
"- (not) unfollowing {}".format(fullhandle))
clog(Fore.WHITE, ("- {}/{} followings left"
.format(local_count, followings_count)))
except Exception as e:
title()
clog(Fore.RED, "- Error: {}".format(str(e)))
if __name__ == '__main__':
main()
@CosineP
Copy link

CosineP commented Sep 9, 2018

Hi, this broke since the last time it was used evidently, it now believes that no one has posted.

The problem comes from GET {user url}, so I changed it to use Mastodon.py's API and it works.

While I was at it, I also added the option to remove people who follow you by block+unblock, this is ~8 extra lines and is triggered with --followers.

I made a fork and put it right here: https://gist.github.com/CosineP/d7ecf4a193dda8ea47765ac697bba410 Hope that's ok and hope this helps the next person needing to do this! <#

@nemobis
Copy link

nemobis commented Jul 27, 2022

@0xa
Copy link
Author

0xa commented Jul 27, 2022

Please add a free license! https://www.gnu.org/licenses/license-list.en.html

no

Hope that's ok and hope this helps the next person needing to do this! <#

thats fine thank's <#

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment