Create a gist now

Instantly share code, notes, and snippets.

@0xa /followings.py
Last active Mar 13, 2018

Embed
What would you like to do?
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()
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment