<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:content="http://purl.org/rss/1.0/modules/content/"><channel><title>Mastodon on Kabelbruch</title><link>https://kabelbruch.de/tags/mastodon/</link><description>Recent content in Mastodon on Kabelbruch</description><image><title>Kabelbruch</title><url>https://kabelbruch.de/og-image.png</url><link>https://kabelbruch.de/og-image.png</link></image><generator>Hugo</generator><language>de-DE</language><lastBuildDate>Tue, 03 Nov 2026 07:45:00 +0100</lastBuildDate><atom:link href="https://kabelbruch.de/tags/mastodon/index.xml" rel="self" type="application/rss+xml"/><item><title>Mein Blog tootet selbst</title><link>https://kabelbruch.de/posts/blog-tootet-selbst-mastodon-hugo/</link><pubDate>Mon, 02 Nov 2026 18:35:00 +0100</pubDate><guid>https://kabelbruch.de/posts/blog-tootet-selbst-mastodon-hugo/</guid><description>Neue Artikel kündigt meine Pipeline selbst auf Mastodon an, darunter steht ein Link zur Diskussion. Ohne JavaScript, und A+ bleibt.</description><content:encoded><![CDATA[<p>Seit dem 1. November hat Kabelbruch ein Konto auf Mastodon: <a href="https://norden.social/@kabelbruch" target="_blank" rel="noopener noreferrer">@kabelbruch@norden.social</a>. 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.</p>
<p>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.</p>
<p>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.</p>
<h2 id="was-im-spiel-ist">Was im Spiel ist</h2>
<p>Die Seite baut <strong>Hugo 0.169.0 extended</strong> mit dem Theme <strong>PaperMod</strong>. Gebaut wird in einer <strong>Gitea-Actions-Pipeline</strong> auf einem eigenen Runner im Homelab, danach geht alles per <code>rsync</code> auf einen kleinen VPS mit Caddy. Wie das zusammenhängt, steht in <a href="/posts/kabelbruch-teil-3-pipeline/">Teil 3 der Serie</a>.</p>
<p>Neu dazu kommen:</p>
<ul>
<li>ein Konto auf einer bestehenden Mastodon-Instanz (bei mir norden.social, keine eigene Instanz)</li>
<li><strong>Python 3</strong> mit <code>python3-yaml</code> im Job-Container, sonst nur die Standardbibliothek</li>
<li>ein Zugriffstoken für die Mastodon-API, das <strong>nur</strong> <code>write:statuses</code> darf</li>
</ul>
<h2 id="erst-mal-beweisen-dass-der-blog-mir-gehört">Erst mal beweisen, dass der Blog mir gehört</h2>
<p>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 <code>rel=&quot;me&quot;</code>. PaperMod bindet eine Datei <code>extend_head.html</code> in den <code>&lt;head&gt;</code> jeder Seite ein, wenn es sie gibt. Bei mir steht darin nur:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-html" data-lang="html"><span class="line"><span class="cl">{{- /* Mastodon-Verifizierung (rel=&#34;me&#34;), lädt nichts nach */ -}}
</span></span><span class="line"><span class="cl"><span class="p">&lt;</span><span class="nt">link</span> <span class="na">rel</span><span class="o">=</span><span class="s">&#34;me&#34;</span> <span class="na">href</span><span class="o">=</span><span class="s">&#34;https://norden.social/@kabelbruch&#34;</span><span class="p">&gt;</span>
</span></span></code></pre></div><p>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.</p>
<h2 id="kommentare-als-link-nicht-als-widget">Kommentare als Link, nicht als Widget</h2>
<p>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.</p>
<p>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.</p>
<p>In der <code>hugo.toml</code> reicht unter <code>[params]</code> ein <code>comments = true</code>, PaperMod bindet dann das Partial <code>comments.html</code> ein. Meins sieht so aus:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-html" data-lang="html"><span class="line"><span class="cl">{{- $toot := &#34;&#34; }}
</span></span><span class="line"><span class="cl">{{- with index hugo.Data &#34;mastodon&#34; }}{{ $toot = index . $.RelPermalink | default &#34;&#34; }}{{ end }}
</span></span><span class="line"><span class="cl"><span class="p">&lt;</span><span class="nt">section</span> <span class="na">class</span><span class="o">=</span><span class="s">&#34;kommentare&#34;</span><span class="p">&gt;</span>
</span></span><span class="line"><span class="cl">  <span class="p">&lt;</span><span class="nt">h2</span><span class="p">&gt;</span>Kommentare<span class="p">&lt;/</span><span class="nt">h2</span><span class="p">&gt;</span>
</span></span><span class="line"><span class="cl">  {{- if $toot }}
</span></span><span class="line"><span class="cl">  <span class="p">&lt;</span><span class="nt">p</span><span class="p">&gt;</span>Fragen, Anmerkungen oder eigene Erfahrungen? <span class="p">&lt;</span><span class="nt">a</span> <span class="na">href</span><span class="o">=</span><span class="s">&#34;{{ $toot }}&#34;</span><span class="p">&gt;</span>Diskutier mit auf Mastodon<span class="p">&lt;/</span><span class="nt">a</span><span class="p">&gt;</span> – einfach auf den Beitrag antworten.<span class="p">&lt;/</span><span class="nt">p</span><span class="p">&gt;</span>
</span></span><span class="line"><span class="cl">  {{- else }}
</span></span><span class="line"><span class="cl">  <span class="p">&lt;</span><span class="nt">p</span><span class="p">&gt;</span>Fragen oder Anmerkungen? Schreib mir auf Mastodon an <span class="p">&lt;</span><span class="nt">a</span> <span class="na">href</span><span class="o">=</span><span class="s">&#34;https://norden.social/@kabelbruch&#34;</span><span class="p">&gt;</span>@kabelbruch@norden.social<span class="p">&lt;/</span><span class="nt">a</span><span class="p">&gt;</span> oder per Mail an <span class="p">&lt;</span><span class="nt">a</span> <span class="na">href</span><span class="o">=</span><span class="s">&#34;mailto:kontakt@kabelbruch.de&#34;</span><span class="p">&gt;</span>kontakt@kabelbruch.de<span class="p">&lt;/</span><span class="nt">a</span><span class="p">&gt;</span>.<span class="p">&lt;/</span><span class="nt">p</span><span class="p">&gt;</span>
</span></span><span class="line"><span class="cl">  {{- end }}
</span></span><span class="line"><span class="cl"><span class="p">&lt;/</span><span class="nt">section</span><span class="p">&gt;</span>
</span></span></code></pre></div><p>Woher kennt Hugo die Toot-URL? Aus einer Datei <code>data/mastodon.json</code>, die so aussieht:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-json" data-lang="json"><span class="line"><span class="cl"><span class="p">{</span>
</span></span><span class="line"><span class="cl">  <span class="nt">&#34;/posts/dns-heimnetz-technitium-fortigate/&#34;</span><span class="p">:</span> <span class="s2">&#34;https://norden.social/@kabelbruch/…&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">  <span class="nt">&#34;/posts/secret-scanner-ki-assistent-passwort/&#34;</span><span class="p">:</span> <span class="s2">&#34;https://norden.social/@kabelbruch/…&#34;</span>
</span></span><span class="line"><span class="cl"><span class="p">}</span>
</span></span></code></pre></div><p>Diese Datei steht <strong>nicht</strong> im Repo, sie ist in der <code>.gitignore</code>. Sie entsteht bei jedem Build neu.</p>
<h2 id="mastodon-ist-das-gedächtnis">Mastodon ist das Gedächtnis</h2>
<p>Die Frage, an der ich am längsten hing: Woher weiß die Pipeline, welcher Artikel schon angekündigt wurde?</p>
<p>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.</p>
<p>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 <code>/posts/&lt;slug&gt;/</code>:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="cl"><span class="k">def</span> <span class="nf">own_toots</span><span class="p">():</span>
</span></span><span class="line"><span class="cl">    <span class="s2">&#34;&#34;&#34;Zuordnung Artikelpfad -&gt; URL des ältesten eigenen Toots, der darauf verlinkt.&#34;&#34;&#34;</span>
</span></span><span class="line"><span class="cl">    <span class="n">_</span><span class="p">,</span> <span class="n">body</span> <span class="o">=</span> <span class="n">http</span><span class="p">(</span><span class="sa">f</span><span class="s2">&#34;</span><span class="si">{</span><span class="n">INSTANCE</span><span class="si">}</span><span class="s2">/api/v1/accounts/lookup?&#34;</span> <span class="o">+</span> <span class="n">urllib</span><span class="o">.</span><span class="n">parse</span><span class="o">.</span><span class="n">urlencode</span><span class="p">({</span><span class="s2">&#34;acct&#34;</span><span class="p">:</span> <span class="n">ACCT</span><span class="p">}))</span>
</span></span><span class="line"><span class="cl">    <span class="n">account_id</span> <span class="o">=</span> <span class="n">json</span><span class="o">.</span><span class="n">loads</span><span class="p">(</span><span class="n">body</span><span class="p">)[</span><span class="s2">&#34;id&#34;</span><span class="p">]</span>
</span></span><span class="line"><span class="cl">    <span class="n">mapping</span><span class="p">,</span> <span class="n">max_id</span> <span class="o">=</span> <span class="p">{},</span> <span class="kc">None</span>
</span></span><span class="line"><span class="cl">    <span class="k">for</span> <span class="n">_</span> <span class="ow">in</span> <span class="nb">range</span><span class="p">(</span><span class="mi">10</span><span class="p">):</span>  <span class="c1"># höchstens 400 Toots zurück – reicht für einen Blog</span>
</span></span><span class="line"><span class="cl">        <span class="n">q</span> <span class="o">=</span> <span class="p">{</span><span class="s2">&#34;limit&#34;</span><span class="p">:</span> <span class="mi">40</span><span class="p">,</span> <span class="s2">&#34;exclude_reblogs&#34;</span><span class="p">:</span> <span class="s2">&#34;true&#34;</span><span class="p">,</span> <span class="s2">&#34;exclude_replies&#34;</span><span class="p">:</span> <span class="s2">&#34;true&#34;</span><span class="p">}</span>
</span></span><span class="line"><span class="cl">        <span class="k">if</span> <span class="n">max_id</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">            <span class="n">q</span><span class="p">[</span><span class="s2">&#34;max_id&#34;</span><span class="p">]</span> <span class="o">=</span> <span class="n">max_id</span>
</span></span><span class="line"><span class="cl">        <span class="n">_</span><span class="p">,</span> <span class="n">body</span> <span class="o">=</span> <span class="n">http</span><span class="p">(</span><span class="sa">f</span><span class="s2">&#34;</span><span class="si">{</span><span class="n">INSTANCE</span><span class="si">}</span><span class="s2">/api/v1/accounts/</span><span class="si">{</span><span class="n">account_id</span><span class="si">}</span><span class="s2">/statuses?&#34;</span> <span class="o">+</span> <span class="n">urllib</span><span class="o">.</span><span class="n">parse</span><span class="o">.</span><span class="n">urlencode</span><span class="p">(</span><span class="n">q</span><span class="p">))</span>
</span></span><span class="line"><span class="cl">        <span class="n">statuses</span> <span class="o">=</span> <span class="n">json</span><span class="o">.</span><span class="n">loads</span><span class="p">(</span><span class="n">body</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">        <span class="k">if</span> <span class="ow">not</span> <span class="n">statuses</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">            <span class="k">break</span>
</span></span><span class="line"><span class="cl">        <span class="k">for</span> <span class="n">s</span> <span class="ow">in</span> <span class="n">statuses</span><span class="p">:</span>  <span class="c1"># neueste zuerst -&gt; später gesetzte Werte sind ältere Toots</span>
</span></span><span class="line"><span class="cl">            <span class="n">text</span> <span class="o">=</span> <span class="n">s</span><span class="o">.</span><span class="n">get</span><span class="p">(</span><span class="s2">&#34;content&#34;</span><span class="p">,</span> <span class="s2">&#34;&#34;</span><span class="p">)</span> <span class="o">+</span> <span class="s2">&#34; &#34;</span> <span class="o">+</span> <span class="p">((</span><span class="n">s</span><span class="o">.</span><span class="n">get</span><span class="p">(</span><span class="s2">&#34;card&#34;</span><span class="p">)</span> <span class="ow">or</span> <span class="p">{})</span><span class="o">.</span><span class="n">get</span><span class="p">(</span><span class="s2">&#34;url&#34;</span><span class="p">)</span> <span class="ow">or</span> <span class="s2">&#34;&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">            <span class="k">for</span> <span class="n">path</span> <span class="ow">in</span> <span class="nb">set</span><span class="p">(</span><span class="n">POST_LINK</span><span class="o">.</span><span class="n">findall</span><span class="p">(</span><span class="n">text</span><span class="p">)):</span>
</span></span><span class="line"><span class="cl">                <span class="n">mapping</span><span class="p">[</span><span class="n">path</span><span class="p">]</span> <span class="o">=</span> <span class="n">s</span><span class="p">[</span><span class="s2">&#34;url&#34;</span><span class="p">]</span>
</span></span><span class="line"><span class="cl">        <span class="n">max_id</span> <span class="o">=</span> <span class="n">statuses</span><span class="p">[</span><span class="o">-</span><span class="mi">1</span><span class="p">][</span><span class="s2">&#34;id&#34;</span><span class="p">]</span>
</span></span><span class="line"><span class="cl">    <span class="k">return</span> <span class="n">mapping</span>
</span></span></code></pre></div><p>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.</p>
<p>Ein Nebeneffekt, den ich beim Vorstellungs-Post bedacht habe: <strong>Jeder</strong> 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.</p>
<h2 id="die-ankündigung">Die Ankündigung</h2>
<p>Der zweite Teil des Skripts, <code>announce</code>, läuft <strong>nach</strong> 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:</p>
<ul>
<li>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.</li>
<li>Er ist <strong>höchstens drei Tage</strong> alt (<code>MAX_AGE_DAYS</code>).</li>
<li>Er ist nicht älter als <code>ANNOUNCE_SINCE</code>. Das steht in der <code>deploy.yml</code> auf 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.</li>
<li>Die Seite ist wirklich live: Das Skript ruft die URL ab und will ein <code>200</code> sehen.</li>
</ul>
<p>Der Toot selbst entsteht aus dem Front Matter: <code>Neuer Artikel:</code> plus Titel, darunter das <code>summary</code>, dann der Link und bis zu fünf Hashtags aus den Tags. Ist das zu lang für 500 Zeichen, wird das <code>summary</code> am letzten Wortende gekürzt. Mastodon zählt jeden Link pauschal mit 23 Zeichen, das rechnet das Skript mit ein.</p>
<p>Gepostet wird mit einem <code>Idempotency-Key</code>, der aus dem Artikelpfad gebildet wird:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="cl"><span class="n">headers</span><span class="o">=</span><span class="p">{</span>
</span></span><span class="line"><span class="cl">    <span class="s2">&#34;Authorization&#34;</span><span class="p">:</span> <span class="sa">f</span><span class="s2">&#34;Bearer </span><span class="si">{</span><span class="n">token</span><span class="si">}</span><span class="s2">&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">    <span class="s2">&#34;Idempotency-Key&#34;</span><span class="p">:</span> <span class="s2">&#34;kabelbruch&#34;</span> <span class="o">+</span> <span class="n">post</span><span class="p">[</span><span class="s2">&#34;path&#34;</span><span class="p">]</span><span class="o">.</span><span class="n">replace</span><span class="p">(</span><span class="s2">&#34;/&#34;</span><span class="p">,</span> <span class="s2">&#34;-&#34;</span><span class="p">),</span>
</span></span><span class="line"><span class="cl">    <span class="s2">&#34;Content-Type&#34;</span><span class="p">:</span> <span class="s2">&#34;application/x-www-form-urlencoded&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl"><span class="p">},</span>
</span></span></code></pre></div><p>Schickt die Pipeline denselben Toot kurz hintereinander zweimal ab, etwa weil ich einen Lauf neu starte, legt Mastodon ihn trotzdem nur einmal an.</p>
<p>Zum Testen gibt es die Gitea-Variable <code>MASTODON_DRY_RUN=1</code>. Dann zeigt das Skript nur, was es posten würde. Lokal geht das genauso:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl"><span class="nv">MASTODON_DRY_RUN</span><span class="o">=</span><span class="m">1</span> python3 .gitea/scripts/mastodon.py announce
</span></span></code></pre></div><h2 id="die-pipeline">Die Pipeline</h2>
<p>In der <code>deploy.yml</code> sind drei Schritte dazugekommen. Der Ausschnitt ohne die SSH- und rsync-Teile:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-yaml" data-lang="yaml"><span class="line"><span class="cl"><span class="nt">env</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">SITE_URL</span><span class="p">:</span><span class="w"> </span><span class="s2">&#34;https://kabelbruch.de&#34;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">MASTODON_INSTANCE</span><span class="p">:</span><span class="w"> </span><span class="s2">&#34;https://norden.social&#34;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">MASTODON_ACCT</span><span class="p">:</span><span class="w"> </span><span class="s2">&#34;kabelbruch&#34;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="c"># Bestand nie automatisch ankündigen – nur Artikel ab diesem Zeitpunkt</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">ANNOUNCE_SINCE</span><span class="p">:</span><span class="w"> </span><span class="s2">&#34;2026-11-01T22:00:00+01:00&#34;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nt">jobs</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">deploy</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">steps</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="c"># … Checkout, Werkzeuge …</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="nt">name</span><span class="p">:</span><span class="w"> </span><span class="l">Toot-Links von Mastodon holen</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">run</span><span class="p">:</span><span class="w"> </span><span class="l">python3 .gitea/scripts/mastodon.py fetch</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="nt">name</span><span class="p">:</span><span class="w"> </span><span class="l">Seite bauen</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">run</span><span class="p">:</span><span class="w"> </span><span class="l">hugo --minify --gc</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="c"># … CSP-Prüfung, Deploy …</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="nt">name</span><span class="p">:</span><span class="w"> </span><span class="l">Neue Artikel auf Mastodon ankündigen</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">id</span><span class="p">:</span><span class="w"> </span><span class="l">announce</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">env</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">          </span><span class="nt">MASTODON_TOKEN</span><span class="p">:</span><span class="w"> </span><span class="l">${{ secrets.MASTODON_TOKEN }}</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">          </span><span class="nt">MASTODON_DRY_RUN</span><span class="p">:</span><span class="w"> </span><span class="l">${{ vars.MASTODON_DRY_RUN }}</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">run</span><span class="p">:</span><span class="w"> </span><span class="l">python3 .gitea/scripts/mastodon.py announce</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="nt">name</span><span class="p">:</span><span class="w"> </span><span class="l">Neu bauen und ausspielen (Link zum Toot)</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">if</span><span class="p">:</span><span class="w"> </span><span class="l">steps.announce.outputs.posted == &#39;true&#39;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">run</span><span class="p">:</span><span class="w"> </span><span class="p">|</span><span class="sd">
</span></span></span><span class="line"><span class="cl"><span class="sd">          hugo --minify --gc
</span></span></span><span class="line"><span class="cl"><span class="sd">          # … CSP-Prüfung, rsync wie oben …</span><span class="w">
</span></span></span></code></pre></div><p>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 <code>announce</code> etwas gepostet, schreibt es deshalb <code>posted=true</code> in die Schritt-Ausgabe, und die Pipeline baut und deployt ein zweites Mal.</p>
<p>Ist Mastodon beim <code>fetch</code> nicht erreichbar, gibt es nur eine Warnung. Die Seite wird dann ohne Toot-Links gebaut, der Deploy läuft trotzdem. Beim <code>announce</code> ist es umgekehrt: Kann das Skript den aktuellen Stand nicht abfragen, postet es gar nichts. Lieber eine Ankündigung zu wenig als eine doppelt.</p>
<h2 id="der-erste-lauf-0-artikel-mit-toot">Der erste Lauf: „0 Artikel mit Toot“</h2>
<p>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.</p>
<p>Ursprünglich hat der zweite Build einfach noch einmal <code>fetch</code> 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.</p>
<p>Die Lösung war einfach: <code>announce</code> kennt die neuen Toot-URLs ja aus der Antwort auf den POST. Es schreibt sie jetzt selbst in <code>data/mastodon.json</code>, und der zweite Build fragt gar nicht mehr nach. Seitdem stehen die Links nach dem ersten Lauf unter den Artikeln.</p>
<h2 id="und-dann-klopften-865-server-an">Und dann klopften 865 Server an</h2>
<p>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.</p>
<p>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 (<a href="/posts/besucherstatistik-matomo-log-import/">wie die funktioniert, steht hier</a>).</p>
<p>Zwischen 22 und 24 Uhr standen da <strong>3577 Abrufe von 865 verschiedenen Mastodon-Servern</strong>. Zu erkennen am User-Agent, der immer so anfängt: <code>Mastodon/4.x (http.rb/…; +https://&lt;instanz&gt;/)</code>.</p>
<p>Der Hintergrund: Bei Mastodon holt nicht ein zentraler Server die Linkvorschau, sondern <strong>jede Instanz selbst</strong>, 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.</p>
<p>Überrascht hat mich, <strong>was</strong> abgerufen wurde:</p>
<table>
	<thead>
			<tr>
					<th>Pfad</th>
					<th>Abrufe</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td><code>/</code> (Startseite)</td>
					<td>1719</td>
			</tr>
			<tr>
					<td><code>/og-image.png</code> (Vorschaubild)</td>
					<td>925</td>
			</tr>
			<tr>
					<td><code>/index.xml</code> (RSS-Feed)</td>
					<td>862</td>
			</tr>
			<tr>
					<td>der Secret-Scanner-Artikel</td>
					<td>39</td>
			</tr>
			<tr>
					<td>der DNS-Artikel</td>
					<td>32</td>
			</tr>
	</tbody>
</table>
<p>Die beiden Artikel, die ich angekündigt hatte, kamen also kaum vor. Fast jede Instanz hat sich die <strong>Startseite</strong> 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.</p>
<h3 id="zählt-matomo-das-als-besuche">Zählt Matomo das als Besuche?</h3>
<p>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.</p>
<p>Sie kam nicht. Vorher standen für den 1. November rund 230 Besuche in Matomo, nachher 229.</p>
<p>Die Erklärung steht in Matomos eigener Bot-Erkennung, einer Bibliothek namens DeviceDetector. Ihre Liste kennt den Eintrag schon:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-yaml" data-lang="yaml"><span class="line"><span class="cl">- <span class="nt">regex</span><span class="p">:</span><span class="w"> </span><span class="s1">&#39;Mastodon/&#39;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">name</span><span class="p">:</span><span class="w"> </span><span class="s1">&#39;Mastodon Bot&#39;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">category</span><span class="p">:</span><span class="w"> </span><span class="s1">&#39;Social Media Agent&#39;</span><span class="w">
</span></span></span></code></pre></div><p>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.</p>
<p><strong>Nachtrag vom 3. November:</strong> 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: <strong>84 Misskey-Instanzen</strong> mit zusammen 183 Abrufen. Misskey ist eine andere Fediverse-Software, die mit Mastodon spricht, sich aber mit <code>Misskey/…</code> 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 <strong>145 Besuche</strong> in der Statistik. Meine falsche Annahme war also nur halb falsch.</p>
<h2 id="stolperfallen--learnings">Stolperfallen &amp; Learnings</h2>
<p><strong>Ein Link reicht oft.</strong> 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.</p>
<p><strong>Zustand dort lassen, wo er ohnehin ist.</strong> 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.</p>
<p><strong>Eine Startgrenze einbauen.</strong> Ohne <code>ANNOUNCE_SINCE</code> hätte der erste Lauf alle älteren Artikel auf einmal angekündigt. Den Dry-Run vorher laufen zu lassen, hat sich gelohnt.</p>
<p><strong>Direkt nach dem Schreiben nicht gleich wieder lesen.</strong> Die öffentliche API kannte die frischen Toots noch nicht. Was man gerade selbst erzeugt hat, merkt man sich besser selbst.</p>
<p><strong>Ein Artikel-Link in einem eigenen Toot ist eine Ankündigung.</strong> Das ist gewollt, man muss es aber im Kopf haben, wenn man selbst etwas postet.</p>
<p><strong>Ein Toot erzeugt Last, aber keine Besucher.</strong> 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.</p>
<h2 id="fazit">Fazit</h2>
<p>Ein Artikel landet jetzt mit einem <code>git push</code> 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.</p>
<h2 id="was-als-nächstes-kommt">Was als Nächstes kommt</h2>
<ul>
<li><strong>Kommentare Stufe 2:</strong> 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 <code>on: schedule</code>.</li>
<li><strong>Die Startseiten-Frage klären:</strong> Warum fast alle Instanzen auf <code>/</code> und nicht auf die Artikel gingen.</li>
</ul>
]]></content:encoded></item></channel></rss>