Fedi got fingerd

You ever come up with a joke so bad, you have to write 75 lines of python to make it happen? Me neither. Until today.

In “small web” circles and particularly on the fediverse, there’s been a bit of a recent resurgence in interest in—of all things—the finger protocol. Dating back to the 1970s, this protocol saw a decade or so of existence as a kind of early social network, before home internet access and more advanced ways of checking our friends’ status arrived in the 90s. The flame has been rekindled for the modern web by sites such as Ben Brown’s HappyNetBox and plan.cat which allow users to sign up and create finger-able plan files of their own.

plan.cat has a nifty feature in that it supports ActivityPub, allowing users’ finger posts to be visible to the wider fediverse.

I wondered about doing the opposite; setting up something so you could finger a user via the proper protocol, and receive their recent posts from fedi. This is almost totally useless, and virtually no-one will want to do this. But as soon as my brain floated up the name “Fedi got fingerd” (after Tom Green’s 2001 opus Freddie Got Fingered), I knew I wasn’t going to get it out of my head until I’d made the damn thing.

Here it is then: a python script designed to be run from a cron job, which grabs your latest posts from your fedi account, converts them to a plain text format, and dumps them into ~/.plan along with your bio. This is then picked up by the finger daemon when someone runs finger [youruser]@[yourserver].

Screenshot with a three-way split tmux, showing the output of the finger command, the source code, and a crontab.

The code is below. You run it as shown in the crontab above: python3 fedi-got-fingered.py [your_fedi_account] [number_of_posts]. You’ll need the requests and beautifulsoup4 plugins. Your fedi account will need to be public. It’s only been tested on Debian 13, Python 3.13 and with a Mastodon server, though it probably works with others.

This code was written with love, not with Claude. Consider it in the Public Domain. Feel free to improve on it, you could hardly make it any worse.

import os
from datetime import datetime

import requests
from bs4 import BeautifulSoup
import re
import sys

def normalise(text):
    """Clean up posts removing non-ASCII text and HTML tags etc."""
    soup = BeautifulSoup(text, features="html.parser")
    clean_text = soup.get_text(separator="\n")
    clean_text = re.sub(r"[^\x00-\x7F]+", "", clean_text)
    clean_text = clean_text.replace("\n#\n", "\n#").replace("\n\n", "\n").replace("\r\n", "\n")
    return clean_text.strip()


# Take username and post count as command line arguments
if len(sys.argv) <= 2:
    print("Please provide username and post count as command-line arguments e.g. 'python3 fedi_got_fingerd.py ian@mastodon.radio 20'.")
    exit(1)

username = sys.argv[1]
post_count = sys.argv[2]
user = username.split("@")[0]
server = username.split("@")[1]

# Set up .plan file handle
f = open(os.path.expanduser("~/.plan"), "w")
if not f:
    print("Could not write to ~/.plan")
    exit(1)

# Query server to get user ID
r = requests.get(f"https://{server}/api/v1/accounts/lookup?acct={username}")
account_data = r.json()
id = account_data["id"]
name = account_data["display_name"]
bio = normalise(account_data["note"])

# Write top section
f.write(f"\n------ {username} ------\n")
f.write(f"{normalise(bio).strip()}\n\n")

# Query server to get toots
r2 = requests.get(f"https://{server}/api/v1/accounts/{id}/statuses", params={ "limit": post_count })
toots = r2.json()

# Find unique dates
dates = list(set([datetime.fromisoformat(t["created_at"]).strftime("%A, %d %B %Y") for t in sorted(toots, key=lambda t: t["created_at"])]))[::-1]

# Iterate through toots and format output
for d in dates:
    f.write(f"--- {d} --- \n\n")
    for t in toots:
        datetime = datetime.fromisoformat(t["created_at"])
        if datetime.strftime("%A, %d %B %Y") == d:
            f.write(f"{datetime.strftime("%H:%M")}\n")
            if t["content"]:
                f.write(f"{normalise(t["content"]).strip()}\n")
            if t["media_attachments"]:
                for a in t["media_attachments"]:
                    if a["description"]:
                        f.write(f"MEDIA: {normalise(a["description"]).strip()}\n")

            # Handle retoots
            if t["reblog"]:
                f.write(f"RT {normalise(t["reblog"]["account"]["acct"])}\n")
                f.write(f"{normalise(t["reblog"]["content"]).strip()}\n")
                if t["reblog"]["media_attachments"]:
                    for a in t["reblog"]["media_attachments"]:
                        if a["description"]:
                            f.write(f"MEDIA: {normalise(a["description"]).strip()}\n")
            f.write(f"{t["url"].replace("/activity", "")}\n")
            f.write("\n")

Comments