Nach der X.com-Integration via Playwright war klar: Ein zweiter Social-Media-Kanal muss her. Die Wahl fiel auf Bluesky — ein dezentrales soziales Netzwerk, das auf dem offenen AT Protocol basiert.
Der entscheidende Vorteil gegenueber X.com: Bluesky bietet eine offizielle, gut dokumentierte API. Kein Playwright, keine Browser-Automatisierung, keine fragilen Selektoren. Ein simpler HTTP-Call genuegt.
Architektur im Vergleich
Bei X.com sieht der Posting-Flow so aus:
- Teaser generieren (Ollama auf dem Mac)
- In die Warteschlange einreihen
- PM2-Worker pollt alle 60 Sekunden
- Playwright startet headless Chromium
- Navigiert zu X.com, findet Textfeld, tippt Text, klickt Button
- Wartet auf API-Response im Netzwerk-Tab
- Extrahiert Post-ID aus der Response
Bei Bluesky:
- Teaser ist bereits generiert (gleicher Text wie fuer X)
- API-Call an
bsky.socialmit App Password - Fertig.
Kein Worker, kein Browser, keine Session-Verwaltung. Der gesamte Bluesky-Client ist unter 60 Zeilen Code.
Technische Umsetzung
AT Protocol SDK
Bluesky nutzt das AT Protocol (Authenticated Transfer Protocol). Das offizielle npm-Paket @atproto/api bietet einen vollstaendigen Client:
npm install @atproto/apiAuthentifizierung via App Password
Statt OAuth oder API-Keys nutzt Bluesky App Passwords — spezielle Passwoerter, die in den Bluesky-Einstellungen erstellt werden. Sie haben eingeschraenkte Rechte (koennen z.B. das Konto-Passwort nicht aendern) und sind jederzeit widerrufbar.
import { BskyAgent } from "@atproto/api";
const agent = new BskyAgent({ service: "https://bsky.social" });
await agent.login({
identifier: "mein-handle.bsky.social",
password: process.env.BLUESKY_APP_PASSWORD,
});Rich Text und Facets
Ein Bluesky-Post ist nicht einfach nur Text. Links, Mentions und Hashtags werden als Facets kodiert — Annotationen mit Byte-Offsets, die angeben, welcher Textbereich ein Link ist.
Das SDK nimmt einem die Arbeit ab:
import { RichText } from "@atproto/api";
const richText = new RichText({
text: "Mein Teaser-Text\n\nhttps://www.macip.de/blog/..."
});
await richText.detectFacets(agent);
await agent.post({
text: richText.text,
facets: richText.facets,
});detectFacets() erkennt automatisch URLs im Text und erstellt die passenden Facet-Objekte mit korrekten Byte-Offsets. Das ist wichtig, weil Bluesky UTF-8-Byte-Offsets verwendet, nicht Character-Indizes — bei Umlauten oder Emojis wuerde man sonst falsche Positionen berechnen.
Der Bluesky-Client
Der gesamte Client in src/lib/bluesky/client.ts:
export async function publishToBluesky(
text: string,
url: string
): Promise<{ uri: string; postUrl: string }> {
const agent = new BskyAgent({ service: "https://bsky.social" });
await agent.login({
identifier: BLUESKY_IDENTIFIER,
password: BLUESKY_APP_PASSWORD,
});
const fullText = `${text}\n\n${url}`;
const richText = new RichText({ text: fullText });
await richText.detectFacets(agent);
const response = await agent.post({
text: richText.text,
facets: richText.facets,
createdAt: new Date().toISOString(),
});
const rkey = response.uri.split("/").pop();
const postUrl = `https://bsky.app/profile/${BLUESKY_IDENTIFIER}/post/${rkey}`;
return { uri: response.uri, postUrl };
}Der Text wird aus dem bereits generierten X-Teaser (x_main_tweet) uebernommen — mit maximal 280 Zeichen passt er problemlos in Bluesky's 300-Zeichen-Limit. Die Blog-URL wird mit Bluesky-spezifischen UTM-Parametern angehaengt.
Integration in den Blog-Editor
Im Dashboard erscheint unter dem X.com-Bereich ein neuer Bluesky-Bereich:
- Vorschau des Posts (gleicher Teaser-Text wie fuer X)
- "Auf Bluesky posten"-Button
- Status-Badge: Veroeffentlicht (gruen) oder Fehlgeschlagen (rot)
- Link zum Post nach erfolgreicher Veroeffentlichung
Der Button ist deaktiviert, wenn noch kein Teaser generiert wurde oder der Post bereits auf Bluesky veroeffentlicht ist.
API-Route
Die Route POST /api/blog/[id]/publish-bsky folgt dem gleichen Muster wie die X.com-Routen:
- Admin-Authentifizierung pruefen
- Post laden und validieren (veroeffentlicht, Teaser vorhanden)
- Pruefen ob bereits auf Bluesky gepostet
publishToBluesky()aufrufen- Ergebnis in der Datenbank speichern
- Logging in
x_publish_logs
Bei Erfolg werden bsky_post_uri, bsky_post_url und bsky_shared_at gespeichert. Bei Fehlern wird bsky_publish_status auf failed gesetzt und die Fehlermeldung in bsky_publish_error protokolliert.
Warum kein Worker?
Bei X.com ist ein PM2-Worker noetig, weil:
- Playwright braucht eine persistente Browser-Session
- X.com hat aggressive Rate-Limits und Bot-Erkennung
- Browser-Automatisierung ist fehleranfaellig und langsam
- Ein fehlgeschlagener Post soll automatisch erneut versucht werden
Bei Bluesky entfallen all diese Gruende:
- Die API ist zuverlaessig und schnell (unter 2 Sekunden)
- App Passwords sind fuer maschinellen Zugriff gedacht
- Bei einem Fehler kann der User einfach erneut klicken
- Kein Session-Management noetig
Datenbank-Erweiterung
Fuenf neue Spalten auf der blog_posts-Tabelle:
| Spalte | Typ | Beschreibung |
|---|---|---|
bsky_post_uri | text | AT-URI des Posts |
bsky_post_url | text | Web-URL zum Post |
bsky_publish_status | text | null / published / failed |
bsky_publish_error | text | Fehlermeldung |
bsky_shared_at | timestamptz | Zeitpunkt der Veroeffentlichung |
Fazit
Die Bluesky-Integration zeigt den Unterschied zwischen einer API-first-Plattform und einer, die APIs aktiv einschraenkt. Waehrend die X.com-Integration ueber 500 Zeilen Playwright-Code, einen PM2-Worker und aufwaendige Session-Verwaltung benoetigt, kommt Bluesky mit unter 60 Zeilen aus.
Der gesamte Stack:
@atproto/api— Offizielles SDK- App Password — Einfache, sichere Authentifizierung
- RichText + Facets — Automatische Link-Erkennung
- Synchroner API-Call — Kein Worker noetig
Das Ergebnis: Ein Klick im Dashboard, und der Blog-Artikel ist auf Bluesky.
Quellcode: Die Implementierung ist Teil des macip.de Open-Source-Projekts.