Seit dem 1. November hat Kabelbruch ein Konto auf Mastodon: @kabelbruch@norden.social. Zwei Dinge wollte ich von Anfang an haben. Neue Artikel sollen dort automatisch angekündigt werden, weil ich das von Hand garantiert vergesse. Und unter jedem Artikel soll ein Weg zur Diskussion stehen.
Die eine Bedingung dabei war nicht verhandelbar: Die Seite bleibt bei A+ im Mozilla Observatory. Also kein Kommentar-Widget, das beim Lesen JavaScript von einer fremden Instanz nachlädt, und keine Lockerung der Content Security Policy.
Herausgekommen sind ein Python-Skript mit gut 200 Zeilen, ein paar Zeilen in der Pipeline und ein Template mit genau einem Link. Am Ende kam noch eine Überraschung in den Zugriffsprotokollen dazu.
Was im Spiel ist
Die Seite baut Hugo 0.169.0 extended mit dem Theme PaperMod. Gebaut wird in einer Gitea-Actions-Pipeline auf einem eigenen Runner im Homelab, danach geht alles per rsync auf einen kleinen VPS mit Caddy. Wie das zusammenhängt, steht in Teil 3 der Serie.
Neu dazu kommen:
- ein Konto auf einer bestehenden Mastodon-Instanz (bei mir norden.social, keine eigene Instanz)
- Python 3 mit
python3-yamlim Job-Container, sonst nur die Standardbibliothek - ein Zugriffstoken für die Mastodon-API, das nur
write:statusesdarf
Erst mal beweisen, dass der Blog mir gehört
Mastodon kann Links im Profil als verifiziert markieren, dann steht ein grüner Haken daneben. Dafür muss die verlinkte Seite zurück auf das Profil zeigen, mit rel="me". PaperMod bindet eine Datei extend_head.html in den <head> jeder Seite ein, wenn es sie gibt. Bei mir steht darin nur:
{{- /* Mastodon-Verifizierung (rel="me"), lädt nichts nach */ -}}
<link rel="me" href="https://norden.social/@kabelbruch">
Das Tag lädt nichts, setzt keinen Cookie und braucht keine Änderung an der CSP. Nach dem nächsten Deploy und einem erneuten Speichern des Profils war der Haken da.
Kommentare als Link, nicht als Widget
Es gibt fertige Lösungen, die die Antworten auf einen Toot per JavaScript unter dem Artikel anzeigen. Das sieht gut aus. Es heißt aber auch: Beim Lesen fragt der Browser jedes Besuchers bei einer Mastodon-Instanz an, und die CSP braucht eine Ausnahme für fremde Server. Beides wollte ich nicht.
Meine Stufe 1 ist deshalb ziemlich bescheiden. Unter jedem Artikel steht ein Abschnitt „Kommentare“. Gibt es zum Artikel einen Toot, verlinkt er dorthin. Gibt es keinen, steht da mein Profil und die Mailadresse.
In der hugo.toml reicht unter [params] ein comments = true, PaperMod bindet dann das Partial comments.html ein. Meins sieht so aus:
{{- $toot := "" }}
{{- with index hugo.Data "mastodon" }}{{ $toot = index . $.RelPermalink | default "" }}{{ end }}
<section class="kommentare">
<h2>Kommentare</h2>
{{- if $toot }}
<p>Fragen, Anmerkungen oder eigene Erfahrungen? <a href="{{ $toot }}">Diskutier mit auf Mastodon</a> – einfach auf den Beitrag antworten.</p>
{{- else }}
<p>Fragen oder Anmerkungen? Schreib mir auf Mastodon an <a href="https://norden.social/@kabelbruch">@kabelbruch@norden.social</a> oder per Mail an <a href="mailto:kontakt@kabelbruch.de">kontakt@kabelbruch.de</a>.</p>
{{- end }}
</section>
Woher kennt Hugo die Toot-URL? Aus einer Datei data/mastodon.json, die so aussieht:
{
"/posts/dns-heimnetz-technitium-fortigate/": "https://norden.social/@kabelbruch/…",
"/posts/secret-scanner-ki-assistent-passwort/": "https://norden.social/@kabelbruch/…"
}
Diese Datei steht nicht im Repo, sie ist in der .gitignore. Sie entsteht bei jedem Build neu.
Mastodon ist das Gedächtnis
Die Frage, an der ich am längsten hing: Woher weiß die Pipeline, welcher Artikel schon angekündigt wurde?
Der naheliegende Weg wäre, sich das irgendwo zu merken. Zum Beispiel könnte die Pipeline die Toot-URL ins Front Matter des Artikels zurückschreiben und committen. Dann bräuchte der Runner aber Schreibrechte auf das Repo, und jeder Deploy erzeugt einen weiteren Commit. Das fühlte sich falsch an.
Stattdessen frage ich einfach Mastodon. Ein Artikel gilt als angekündigt, sobald einer meiner eigenen Toots auf seine URL verlinkt. Mehr Zustand gibt es nicht. Das Skript holt dafür über die öffentliche API die letzten Toots des Kontos und sucht darin nach Links auf /posts/<slug>/:
def own_toots():
"""Zuordnung Artikelpfad -> URL des ältesten eigenen Toots, der darauf verlinkt."""
_, body = http(f"{INSTANCE}/api/v1/accounts/lookup?" + urllib.parse.urlencode({"acct": ACCT}))
account_id = json.loads(body)["id"]
mapping, max_id = {}, None
for _ in range(10): # höchstens 400 Toots zurück – reicht für einen Blog
q = {"limit": 40, "exclude_reblogs": "true", "exclude_replies": "true"}
if max_id:
q["max_id"] = max_id
_, body = http(f"{INSTANCE}/api/v1/accounts/{account_id}/statuses?" + urllib.parse.urlencode(q))
statuses = json.loads(body)
if not statuses:
break
for s in statuses: # neueste zuerst -> später gesetzte Werte sind ältere Toots
text = s.get("content", "") + " " + ((s.get("card") or {}).get("url") or "")
for path in set(POST_LINK.findall(text)):
mapping[path] = s["url"]
max_id = statuses[-1]["id"]
return mapping
Für diesen Teil braucht es kein Token. Antworten und Boosts lasse ich weg, damit eine Antwort von mir mit Link auf einen Artikel nicht plötzlich als dessen Ankündigung gilt.
Ein Nebeneffekt, den ich beim Vorstellungs-Post bedacht habe: Jeder eigene Toot mit einem Artikel-Link zählt. Hätte ich dort einen Artikel verlinkt, hätte die Pipeline ihn nie mehr angekündigt. Im Vorstellungs-Post steht deshalb nur die Startseite.
Die Ankündigung
Der zweite Teil des Skripts, announce, läuft nach dem Deploy. Er geht alle Artikel durch und postet für jeden, der noch keinen Toot hat. Allerdings nur, wenn alle diese Bedingungen erfüllt sind:
- Der Artikel ist kein Entwurf und sein Datum liegt nicht in der Zukunft. Hugo baut solche Artikel nicht, also gibt es auch nichts anzukündigen.
- Er ist höchstens drei Tage alt (
MAX_AGE_DAYS). - Er ist nicht älter als
ANNOUNCE_SINCE. Das steht in derdeploy.ymlauf dem Zeitpunkt, an dem ich das Ganze eingeschaltet habe. Ohne diese Grenze hätte der erste Lauf alle bisherigen Artikel auf einmal in die Timeline gekippt. - Die Seite ist wirklich live: Das Skript ruft die URL ab und will ein
200sehen.
Der Toot selbst entsteht aus dem Front Matter: Neuer Artikel: plus Titel, darunter das summary, dann der Link und bis zu fünf Hashtags aus den Tags. Ist das zu lang für 500 Zeichen, wird das summary am letzten Wortende gekürzt. Mastodon zählt jeden Link pauschal mit 23 Zeichen, das rechnet das Skript mit ein.
Gepostet wird mit einem Idempotency-Key, der aus dem Artikelpfad gebildet wird:
headers={
"Authorization": f"Bearer {token}",
"Idempotency-Key": "kabelbruch" + post["path"].replace("/", "-"),
"Content-Type": "application/x-www-form-urlencoded",
},
Schickt die Pipeline denselben Toot kurz hintereinander zweimal ab, etwa weil ich einen Lauf neu starte, legt Mastodon ihn trotzdem nur einmal an.
Zum Testen gibt es die Gitea-Variable MASTODON_DRY_RUN=1. Dann zeigt das Skript nur, was es posten würde. Lokal geht das genauso:
MASTODON_DRY_RUN=1 python3 .gitea/scripts/mastodon.py announce
Die Pipeline
In der deploy.yml sind drei Schritte dazugekommen. Der Ausschnitt ohne die SSH- und rsync-Teile:
env:
SITE_URL: "https://kabelbruch.de"
MASTODON_INSTANCE: "https://norden.social"
MASTODON_ACCT: "kabelbruch"
# Bestand nie automatisch ankündigen – nur Artikel ab diesem Zeitpunkt
ANNOUNCE_SINCE: "2026-11-01T22:00:00+01:00"
jobs:
deploy:
steps:
# … Checkout, Werkzeuge …
- name: Toot-Links von Mastodon holen
run: python3 .gitea/scripts/mastodon.py fetch
- name: Seite bauen
run: hugo --minify --gc
# … CSP-Prüfung, Deploy …
- name: Neue Artikel auf Mastodon ankündigen
id: announce
env:
MASTODON_TOKEN: ${{ secrets.MASTODON_TOKEN }}
MASTODON_DRY_RUN: ${{ vars.MASTODON_DRY_RUN }}
run: python3 .gitea/scripts/mastodon.py announce
- name: Neu bauen und ausspielen (Link zum Toot)
if: steps.announce.outputs.posted == 'true'
run: |
hugo --minify --gc
# … CSP-Prüfung, rsync wie oben …
Der Ablauf hat ein kleines Henne-Ei-Problem. Der Toot kann erst raus, wenn der Artikel live ist. Der Link „Diskutier mit auf Mastodon“ kann aber erst in den Artikel, wenn es den Toot gibt. Hat announce etwas gepostet, schreibt es deshalb posted=true in die Schritt-Ausgabe, und die Pipeline baut und deployt ein zweites Mal.
Ist Mastodon beim fetch nicht erreichbar, gibt es nur eine Warnung. Die Seite wird dann ohne Toot-Links gebaut, der Deploy läuft trotzdem. Beim announce ist es umgekehrt: Kann das Skript den aktuellen Stand nicht abfragen, postet es gar nichts. Lieber eine Ankündigung zu wenig als eine doppelt.
Der erste Lauf: „0 Artikel mit Toot“
Der erste echte Lauf hat brav zwei Toots abgesetzt. Danach sollte der zweite Build die Links unter die Artikel setzen. Im Log stand aber „0 Artikel mit Toot“, und unter den Artikeln stand weiter der Hinweis auf Profil und Mail.
Ursprünglich hat der zweite Build einfach noch einmal fetch aufgerufen. Die öffentliche API hat direkt nach dem Posten aber noch den alten Stand geliefert, ohne die beiden neuen Toots. Ich vermute einen Cache für anonyme Abfragen, genauer untersucht habe ich das nicht.
Die Lösung war einfach: announce kennt die neuen Toot-URLs ja aus der Antwort auf den POST. Es schreibt sie jetzt selbst in data/mastodon.json, und der zweite Build fragt gar nicht mehr nach. Seitdem stehen die Links nach dem ersten Lauf unter den Artikeln.
Und dann klopften 865 Server an
Vorher hatte ich das Konto mit einem kurzen Vorstellungs-Post eröffnet. Der ist überraschend gut aufgenommen worden: einige persönliche Begrüßungen, dazu Boosts und Favoriten. Damit hatte ich ehrlich gesagt nicht gerechnet.
Die ersten beiden automatischen Toots gingen am 1. November um 23:23 Uhr raus. Am nächsten Tag habe ich mir die Zugriffsprotokolle des Webservers angesehen, eigentlich nur, um die Besucherstatistik aufzuräumen (wie die funktioniert, steht hier).
Zwischen 22 und 24 Uhr standen da 3577 Abrufe von 865 verschiedenen Mastodon-Servern. Zu erkennen am User-Agent, der immer so anfängt: Mastodon/4.x (http.rb/…; +https://<instanz>/).
Der Hintergrund: Bei Mastodon holt nicht ein zentraler Server die Linkvorschau, sondern jede Instanz selbst, sobald ein Beitrag mit Link bei ihr ankommt. Durch Boosts und Relays landet so ein Beitrag auf sehr vielen Instanzen. Jede ruft dann einmal die Seite ab und holt sich das Vorschaubild.
Überrascht hat mich, was abgerufen wurde:
| Pfad | Abrufe |
|---|---|
/ (Startseite) | 1719 |
/og-image.png (Vorschaubild) | 925 |
/index.xml (RSS-Feed) | 862 |
| der Secret-Scanner-Artikel | 39 |
| der DNS-Artikel | 32 |
Die beiden Artikel, die ich angekündigt hatte, kamen also kaum vor. Fast jede Instanz hat sich die Startseite angesehen. Warum, weiß ich nicht sicher. Meine Vermutung: Das hängt eher mit dem Profil und dem Vorstellungs-Post zusammen, der in diesen Stunden über die Relays verteilt wurde, als mit den beiden neuen Toots. Geprüft habe ich das nicht.
Zählt Matomo das als Besuche?
Das war meine eigentliche Sorge. 865 Server als „Besucher“ hätten die Statistik vom 1. November komplett verfälscht. Ich war mir ziemlich sicher, dass genau das passiert ist, und habe einen Filter gebaut, der diese Abrufe vor dem Import aussortiert. Dann habe ich den Tag neu importiert und auf die große Korrektur gewartet.
Sie kam nicht. Vorher standen für den 1. November rund 230 Besuche in Matomo, nachher 229.
Die Erklärung steht in Matomos eigener Bot-Erkennung, einer Bibliothek namens DeviceDetector. Ihre Liste kennt den Eintrag schon:
- regex: 'Mastodon/'
name: 'Mastodon Bot'
category: 'Social Media Agent'
Matomo hatte die 865 Server also längst als Bots aussortiert. Meine Annahme war falsch, und die Zahlen vom 1. November waren schon richtig. Den Filter habe ich trotzdem behalten. Er fängt noch ein paar andere Dinge ab, die Matomo nicht erkennt, und ich verlasse mich nicht gern darauf, dass eine fremde Liste zufällig passt.
Nachtrag vom 3. November: Ganz so glatt war es dann doch nicht. Am nächsten Morgen habe ich mir angesehen, was nach dem Filter noch auf der Startseite landete, und dabei kam eine zweite Welle zum Vorschein: 84 Misskey-Instanzen mit zusammen 183 Abrufen. Misskey ist eine andere Fediverse-Software, die mit Mastodon spricht, sich aber mit Misskey/… meldet. Die kannte weder Matomos Bot-Liste noch mein Filter, also hat Matomo jede Instanz brav als Besucher gezählt. Seit ich sie zusätzlich aussortiere, stehen für den 1. November nicht mehr 229, sondern 145 Besuche in der Statistik. Meine falsche Annahme war also nur halb falsch.
Stolperfallen & Learnings
Ein Link reicht oft. Ich hatte kurz überlegt, Antworten per JavaScript einzubinden. Ein Link auf den Toot kostet nichts, verrät beim Lesen niemandem etwas und hält die CSP sauber.
Zustand dort lassen, wo er ohnehin ist. Die Toots gibt es auf Mastodon sowieso. Sie noch einmal ins Repo zu schreiben, hätte nur eine zweite Wahrheit erzeugt, die irgendwann nicht mehr stimmt.
Eine Startgrenze einbauen. Ohne ANNOUNCE_SINCE hätte der erste Lauf alle älteren Artikel auf einmal angekündigt. Den Dry-Run vorher laufen zu lassen, hat sich gelohnt.
Direkt nach dem Schreiben nicht gleich wieder lesen. Die öffentliche API kannte die frischen Toots noch nicht. Was man gerade selbst erzeugt hat, merkt man sich besser selbst.
Ein Artikel-Link in einem eigenen Toot ist eine Ankündigung. Das ist gewollt, man muss es aber im Kopf haben, wenn man selbst etwas postet.
Ein Toot erzeugt Last, aber keine Besucher. Hunderte Instanzen holen sich Seite und Vorschaubild ab. Matomo erkennt Mastodon von sich aus, andere Fediverse-Software wie Misskey aber nicht. Wer saubere Zahlen will, muss selbst nachsehen, wer da anklopft.
Fazit
Ein Artikel landet jetzt mit einem git push auf der Seite und auf Mastodon, und unter dem Artikel steht der passende Link zur Diskussion. Die Seite lädt dafür nichts nach und bleibt bei A+. Und ich habe gelernt, dass ein einzelner Toot fast tausend Server auf meine Startseite schickt und dass die Statistik nur einen Teil davon von selbst erkennt.
Was als Nächstes kommt
- Kommentare Stufe 2: Die Antworten auf den Toot beim Build über die API holen und als statisches HTML unter den Artikel setzen, nur Text und bereinigt. Dafür bräuchte die Pipeline einen regelmäßigen Rebuild, zum Beispiel per
on: schedule. - Die Startseiten-Frage klären: Warum fast alle Instanzen auf
/und nicht auf die Artikel gingen.