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) " "(default: %(default)s)")) 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('--followers', action='store_true', help="Instead of removing people you follow, remove " "people who follow YOU") parser.add_argument('--unmutuals', action='store_true', help="Remove people who follow you but that you " "don't follow") parser.add_argument('-v', '--verbose', action='store_true', help="Display more things") args = parser.parse_args() session = requests.Session() mastodon = Mastodon(access_token=settings.ACCESS_TOKEN, api_base_url=settings.API_BASE, ratelimit_method='pace') current_user = mastodon.account_verify_credentials() uid = current_user['id'] if args.unmutuals: args.followers = True if args.followers: followings_count = current_user['followers_count'] else: 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, "{}: {} {}".format("Followers" if args.followers else "Followings", followings_count, goal_msg)) if args.unfollow: cprint(Fore.RED, "Action: unfollow") else: cprint(Fore.YELLOW, "Action: none") followings = None if args.followers: followings = mastodon.account_followers(uid) else: 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.unmutuals and inst not in settings.SKIP_INSTANCES: try: relations = mastodon.account_relationships(fid) is_mutual = relations[0]["following"] or relations[0][ "requested"] if not is_mutual: clog(Fore.YELLOW, "- Unmutual ({})".format(relations)) act = True except (UserGone, Error, requests.RequestException) as e: act = False clog(Fore.YELLOW, "- Exception ({})".format(e)) elif args.min_activity and inst not in settings.SKIP_INSTANCES: try: last_toot = get_last_toot(mastodon, fid) if last_toot < now - args.min_activity: # force a cache miss to be Sure last_toot = get_last_toot(mastodon, fid, force=True) 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: if args.followers: clog(Fore.GREEN + Style.BRIGHT, "- Removing follower {}".format(fullhandle)) mastodon.account_block(fid) mastodon.account_unblock(fid) else: 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)))
def backup(args): """ Backup toots, followers, etc from your Mastodon account """ (username, domain) = args.user.split("@") url = 'https://' + domain client_secret = domain + '.client.secret' user_secret = domain + '.user.' + username + '.secret' status_file = domain + '.user.' + username + '.json' data = None if os.path.isfile(status_file): print("Loading existing backup") with open(status_file, mode = 'r', encoding = 'utf-8') as fp: data = json.load(fp) if not os.path.isfile(client_secret): print("Registering app") Mastodon.create_app( 'mastodon-backup', api_base_url = url, to_file = client_secret) if not os.path.isfile(user_secret): print("Log in") mastodon = Mastodon( client_id = client_secret, api_base_url = url) url = mastodon.auth_request_url( client_id = client_secret, scopes=['read']) print("Visit the following URL and authorize the app:") print(url) print("Then paste the access token here:") token = sys.stdin.readline().rstrip() mastodon.log_in( username = username, code = token, to_file = user_secret, scopes=['read']) else: mastodon = Mastodon( client_id = client_secret, access_token = user_secret, api_base_url = url) print("Get user info") user = mastodon.account_verify_credentials() def find_id(list, id): """Return the list item whose id attribute matches.""" return next((item for item in list if item["id"] == id), None) def fetch_up_to(page, id): statuses = [] # use a generator expression to find our last status found = find_id(page, id) # get the remaining pages while len(page) > 0 and found is None: statuses.extend(page) sys.stdout.flush() page = mastodon.fetch_next(page) if page is None: break found = find_id(page, id) page = page[0:page.index(found)] statuses.extend(page) print("Fetched a total of %d new toots" % len(statuses)) return statuses if data is None or not "statuses" in data: print("Get statuses (this may take a while)") statuses = mastodon.account_statuses(user["id"]) statuses = mastodon.fetch_remaining( first_page = statuses) else: id = data["statuses"][0]["id"] print("Get new statuses") statuses = fetch_up_to(mastodon.account_statuses(user["id"]), id) statuses.extend(data["statuses"]) if data is None or not "favourites" in data: print("Get favourites (this may take a while)") favourites = mastodon.favourites() favourites = mastodon.fetch_remaining( first_page = favourites) else: id = data["favourites"][0]["id"] print("Get new favourites") favourites = fetch_up_to(mastodon.favourites(), id) favourites.extend(data["favourites"]) data = { 'account': user, 'statuses': statuses, 'favourites': favourites } print("Saving %d statuses and %d favourites" % ( len(statuses), len(favourites))) date_handler = lambda obj: ( obj.isoformat() if isinstance(obj, (datetime.datetime, datetime.date)) else None) with open(status_file, mode = 'w', encoding = 'utf-8') as fp: data = json.dump(data, fp, indent = 2, default = date_handler)