LinkedIn-Integration — Playwright statt API, Queue statt Chaos
Nach X.com via Playwright, Bluesky via AT Protocol und Mastodon via fetch() fehlte noch eine Plattform: LinkedIn — das professionelle Netzwerk, in dem technische Blog-Artikel tatsaechlich gelesen und geteilt werden.
Das Ergebnis: Eine Queue-basierte Playwright-Integration, die das gleiche Muster wie die X.com-Automatisierung nutzt — mit strengeren Rate-Limits und einem robusteren Fehlerhandling.
Warum nicht die LinkedIn API?
LinkedIn bietet zwar eine API, aber sie ist fuer Einzelentwickler de facto unbrauchbar:
- Community Management API erfordert ein LinkedIn-Partner-Programm
- Marketing Developer Platform verlangt eine Unternehmensseite + Approval-Prozess
- Self-Service-Zugang fuer persoenliche Profile gibt es nicht
Wer einfach nur einen Post auf seinem eigenen Profil veroeffentlichen will, steht vor verschlossenen Tueren. Die Situation ist aehnlich wie bei X.com — nur dass X wenigstens eine (teure) API anbietet.
Die Konsequenz: Wieder Playwright.
Architektur im Vergleich
Die vier Social-Media-Integrationen unterscheiden sich deutlich in ihrer Komplexitaet:
| Aspekt | X.com | Bluesky | Mastodon | |
|---|---|---|---|---|
| Methode | Playwright | AT Protocol SDK | REST API | Playwright |
| Auth | Session-Cookies | App Password | Bearer Token | Session-Cookies |
| Worker noetig | Ja (PM2) | Nein | Nein | Ja (PM2) |
| Rate-Limit | 60s Abstand | Keins | Keins | 60 Min + 2/Tag |
| Link-Vorschau | Automatisch | Via Facets | OpenGraph | OpenGraph |
| Neue Dateien | ~8 | 2 | 2 | 6 |
| Komplexitaet | Hoch | Mittel | Niedrig | Hoch |
LinkedIn und X.com teilen sich die gleiche Architektur-Kategorie: Browser-Automatisierung mit Queue und Worker. Der Link Shortener wird bei LinkedIn nicht benoetigt — das Zeichenlimit ist kein Problem.
Der Posting-Flow
Dashboard → API Route (202) → Supabase Queue → PM2 Worker → Playwright → LinkedIn
- Admin klickt "Auf LinkedIn posten" im Blog-Editor
- Die API-Route validiert (Admin? Veroeffentlicht? Teaser vorhanden?) und setzt
linkedin_publish_status = 'queued' - Der PM2-Worker pollt alle 5 Minuten nach neuen Eintraegen
- Playwright startet Chrome, laedt die gespeicherte Session und postet
Das gleiche Pattern wie beim X.com-Worker — mit einem entscheidenden Unterschied: LinkedIn ist deutlich strenger bei der Bot-Erkennung.
Session-Management
LinkedIn authentifiziert ueber Session-Cookies. Die Session wird einmalig per Bootstrap-Script erstellt:
npm run linkedin:bootstrapDas oeffnet einen sichtbaren Chrome-Browser. Man loggt sich manuell bei LinkedIn ein und das Script speichert die Login-Cookies. Das muss nur einmal gemacht werden — danach funktioniert das automatische Posten, bis die Session irgendwann ablaeuft (typisch mehrere Wochen).
Bei jedem Posting speichert der Worker automatisch den aktuellen Cookie-Stand zurueck. Wenn LinkedIn die Cookies im Hintergrund refresht, gehen sie nicht verloren.
Anti-Bot-Massnahmen
LinkedIn erkennt Browser-Automatisierung aggressiv. Die Gegenstrategie ist dieselbe wie bei X.com:
- Menschenaehnliches Timing — variables Tippen, natuerliche Pausen zwischen Aktionen, Jitter bei Wartezeiten
- Browser-Fingerprint — Einstellungen, die den Browser wie einen regulaeren Desktop-Client erscheinen lassen
- Deutsche Locale (
de-DE) und Berliner Zeitzone
Die Timing-Funktionen werden aus dem X.com-Modul wiederverwendet. Kein Grund, das Rad neu zu erfinden.
Rate-Limiting — strenger als X.com
LinkedIn ist deutlich empfindlicher als X.com. Der Worker erzwingt drei Limits:
const POLL_INTERVAL_MS = 5 * 60 * 1000; // Alle 5 Min pruefen
const MIN_INTERVAL_MS = 60 * 60 * 1000; // 60 Min zwischen Posts
const MAX_POSTS_PER_DAY = 2; // Max. 2 Posts pro Tag
const MAX_ATTEMPTS = 3; // 3 Versuche, dann aufgebenZum Vergleich: Der X.com-Worker pollt jede Minute und hat kein Tageslimit. Bei LinkedIn ist Zurueckhaltung Pflicht — zu viele Posts fuehren zu Account-Einschraenkungen.
Challenge-Erkennung
Wenn LinkedIn misstrauisch wird, gibt es verschiedene Hindernisse: CAPTCHAs, Checkpoint-Seiten oder ein Redirect zum Login. Der Publisher erkennt alle drei Faelle automatisch — per URL-Analyse und DOM-Pruefung.
Bei einer Challenge beendet sich der Worker mit Exit-Code 1. PM2 startet ihn nach 60 Sekunden neu — aber der Status in der Datenbank bleibt auf challenge_detected oder review_required, bis manuell eingegriffen wird.
Zentralisierte Selektoren
LinkedIn aendert seine DOM-Struktur regelmaessig. Deshalb sind alle CSS-Selektoren in einer einzigen Datei zentralisiert. Wenn LinkedIn die Klassen aendert, muss nur diese eine Datei angepasst werden — nicht der gesamte Publisher-Code. Das gleiche Pattern nutzen wir auch bei der X.com-Integration.
Die API-Route
Die Route folgt dem gleichen Schema wie Bluesky und Mastodon — mit einem Unterschied: Sie gibt 202 Accepted zurueck statt das Ergebnis direkt zu liefern.
POST /api/blog/[id]/publish-linkedin
Die Route validiert:
- Admin-Berechtigung (JWT-Check)
- Post ist veroeffentlicht
- Teaser-Text existiert (aus der Ollama-Generierung)
- Noch nicht auf LinkedIn gepostet oder in der Queue
Dann wird linkedin_publish_status = 'queued' gesetzt und der Worker uebernimmt. Der Teaser-Text (x_main_tweet) wird wiederverwendet — er passt zu allen Plattformen, da er maximal 280 Zeichen hat.
PM2-Konfiguration
Der Worker laeuft als separater PM2-Prozess im Fork-Mode:
{
name: 'linkedin-publisher',
script: './node_modules/.bin/tsx',
args: 'scripts/linkedin/publish-worker.ts',
exec_mode: 'fork',
instances: 1,
env: { DISPLAY: ':99' },
restart_delay: 60000,
max_restarts: 3,
}Wichtig: exec_mode: "fork" statt "cluster". Der Grund ist derselbe wie beim Teaser-Worker: tsx produziert im Cluster-Mode keine Logs. DISPLAY: ':99' verweist auf einen virtuellen Framebuffer (Xvfb) auf dem Server, da Playwright auch im Headless-Mode gelegentlich ein Display braucht.
Fehler-Status-Mapping
Nicht jeder Fehler ist gleich. Der Worker mappt Error-Codes auf differenzierte Status-Werte:
| Error Code | DB-Status | Bedeutung |
|---|---|---|
challenge_detected | challenge_detected | LinkedIn blockiert — manueller Eingriff |
session_expired | review_required | Cookies abgelaufen — Bootstrap noetig |
no_session | review_required | Keine Session vorhanden |
selector_failed | selector_failed | DOM hat sich geaendert |
timeout | failed | Seite laed nicht |
unknown | failed | Unerwarteter Fehler |
Im Dashboard zeigt die Blog-Tabelle den Status als farbiges Icon: Teal bei Erfolg, gedimmt bei Fehler, pulsierend blau bei laufender Verarbeitung.
Post-URL-Extraktion
LinkedIn liefert nach dem Posten keine direkte Bestaetigung wie Bluesky (AT-URI) oder Mastodon (Post-ID). Die Loesung: Nach dem Post navigiert der Worker zur eigenen Activity-Seite und extrahiert den neuesten Post-Link.
Das ist best-effort — wenn es scheitert, ist der Post trotzdem online. Die URL wird dann einfach nicht in der Datenbank gespeichert.
Was die vier Integrationen gemeinsam haben
Alle vier Social-Media-Kanaele teilen sich:
- Den gleichen Teaser-Text (
x_main_tweet— lokal generiert mit Ollama) - Die gleiche Blog-URL mit UTM-Parametern (nur
utm_sourcevariiert) - Das gleiche Dashboard-UI-Pattern (Status-Badge, Post-Link, Action-Button)
- Die gleiche Logging-Tabelle (
x_publish_logs)
Der Unterschied liegt im Transport: API-Call (Bluesky, Mastodon) oder Browser-Automatisierung (X.com, LinkedIn). Die Queue-Architektur bei den Playwright-Integrationen puffert zuverlaessig — selbst wenn der Worker mal ausfaellt, bleibt der Post in der Warteschlange.
Fazit
LinkedIn ist die vierte und aufwaendigste Social-Media-Integration. Aber dank der bestehenden Patterns aus der X.com-Automatisierung war die Umsetzung ueberschaubar: Session-Management, menschenaehnliches Timing und Challenge-Erkennung waren schon geloest. Der LinkedIn-Adapter baut darauf auf und fuegt strengere Rate-Limits und differenziertes Fehlerhandling hinzu.
Der gesamte Social-Media-Stack — von der KI-Teaser-Generierung ueber den Link Shortener bis zum Cross-Posting auf vier Plattformen — laeuft komplett self-hosted, ohne externe APIs und ohne laufende Kosten. Ein Klick im Dashboard, den Rest erledigen die Worker.