#!/usr/bin/env python3 import argparse import csv import json import re import ssl import sys import urllib.error import urllib.parse import urllib.request from typing import Optional, Tuple def die(msg: str, code: int = 1): print(msg, file=sys.stderr) sys.exit(code) def http_get_json(url: str) -> Tuple[object, Optional[str]]: req = urllib.request.Request(url, headers={"User-Agent": "mastodon-export/1.1"}) ctx = ssl.create_default_context() with urllib.request.urlopen(req, context=ctx) as resp: body = resp.read().decode("utf-8") link = resp.headers.get("Link") return json.loads(body), link def parse_next_link(link_header: Optional[str]) -> Optional[str]: if not link_header: return None for part in link_header.split(','): if 'rel="next"' in part: m = re.search(r'<([^>]+)>', part) if m: return m.group(1) return None def normalize_account_address(item: dict) -> Optional[str]: acct = item.get("acct", "") url = item.get("url", "") if not acct: return None if "@" in acct: return acct.lower() m = re.match(r'https?://([^/]+)/', url) if not m: return None return f"{acct}@{m.group(1)}".lower() def build_parser() -> argparse.ArgumentParser: parser = argparse.ArgumentParser( description="Export followers or following of a Mastodon account into an importable follows CSV." ) parser.add_argument( "mode", choices=["followers", "following"], help="What to export: followers or following" ) parser.add_argument( "account", help="Account name, e.g. username or @username@instance" ) parser.add_argument( "base_instance", help="Instance to query, e.g. mastodon.social" ) parser.add_argument( "output", nargs="?", help="Optional output CSV path" ) return parser def main(): parser = build_parser() args = parser.parse_args() mode = args.mode input_acct = args.account base_instance = args.base_instance.removeprefix("https://").removeprefix("http://").strip("/") outfile = args.output or f"{mode}_to_follow_import.csv" lookup_acct = input_acct[1:] if input_acct.startswith("@") else input_acct lookup_url = "https://{}/api/v1/accounts/lookup?{}".format( base_instance, urllib.parse.urlencode({"acct": lookup_acct}) ) try: lookup_json, _ = http_get_json(lookup_url) except urllib.error.HTTPError as e: die(f"Lookup failed: HTTP {e.code} for {lookup_url}") except urllib.error.URLError as e: die(f"Lookup failed: {e.reason}") account_id = str(lookup_json.get("id", "")) if not account_id: die("Account not found") next_url = f"https://{base_instance}/api/v1/accounts/{account_id}/{mode}?limit=80" rows = set() total_seen = 0 while next_url: try: data, link = http_get_json(next_url) except urllib.error.HTTPError as e: die(f"Fetching {mode} failed: HTTP {e.code} for {next_url}") except urllib.error.URLError as e: die(f"Fetching {mode} failed: {e.reason}") if not isinstance(data, list): die("Unexpected API response; expected a JSON array") for item in data: total_seen += 1 addr = normalize_account_address(item) if addr: rows.add(addr) next_url = parse_next_link(link) with open(outfile, "w", newline="", encoding="utf-8") as f: writer = csv.writer(f) writer.writerow(["Account address", "Show boosts", "Notify on new posts", "Languages"]) for addr in sorted(rows): writer.writerow([addr, "true", "false", ""]) print(f"Mode: {mode}") print(f"Resolved account: {lookup_json.get('acct', '')}") print(f"Profile URL: {lookup_json.get('url', '')}") print(f"Accounts seen: {total_seen}") print(f"Importable follows written: {len(rows)}") print(f"CSV file: {outfile}") if __name__ == "__main__": main()