Blog

· 5 Min. Lesezeit

Einen dlvr.it-Klon bauen: neue Blogbeiträge automatisch auf X, LinkedIn und Bluesky teilen

Bau dir deinen eigenen Auto-Poster von RSS zu Social Media: Beobachte einen beliebigen Blog- oder Podcast-Feed und teile jeden neuen Beitrag auf X, LinkedIn, Bluesky, Discord, Slack und Telegram – in der passenden Länge für jedes Netzwerk. Rund 90 Zeilen Node, ohne Abhängigkeiten.

dlvr.it kann eine Sache richtig gut: Du veröffentlichst einen Blogbeitrag, und ein paar Minuten später liegt er auf deinen Social-Media-Konten. Kein Kopieren, nichts vergessen.

Die Aufgabe hat zwei Hälften:

  • Den Feed beobachten. RSS holen, erkennen, was neu ist, und merken, was du schon geteilt hast. Das sind ungefähr 60 Zeilen.
  • Überall posten. OAuth für jedes Netzwerk, Token-Refresh, ein eigenes Längenlimit pro Netzwerk und denselben Beitrag nie zweimal veröffentlichen. Das ist die Infrastruktur, und genau das ist Hapi.

Am Ende hast du ein kleines Skript, das:

  • jeden RSS- oder Atom-Feed beobachtet: deinen Blog, einen Podcast, einen YouTube-Kanal, ein Changelog
  • jeden neuen Eintrag auf X, LinkedIn und Bluesky teilt (und auf Discord, Slack und Telegram, wenn du willst)
  • in einer einzigen Anfrage eine kurze Fassung für X und Bluesky schreibt und eine längere mit Zusammenfassung für LinkedIn
  • nie doppelt postet, auch wenn es abstürzt, neu startet oder die Datei verliert, in der die schon geteilten IDs stehen

Eine Datei, Node 22 oder neuer, keine Abhängigkeiten, keine Datenbank.

1. Hapi einrichten

  1. Starte eine Hapi-Testphase (15 Tage für 5 $).
  2. Verbinde deine Konten unter Accounts: X und LinkedIn mit einem Klick, Bluesky mit einem App-Passwort.
  3. Erstelle einen API-Key.

2. Einen Feed ohne Bibliothek lesen

RSS und Atom sind beides „eine Liste von Einträgen mit Titel, Link und ID“. Ein paar reguläre Ausdrücke reichen für die Feeds, die dir in der Praxis begegnen (WordPress, Ghost, Substack, Medium, Podcasts, YouTube):

js
const tag = (xml, name) => xml.match(new RegExp(`<${name}[^>]*>([\\s\\S]*?)</${name}>`))?.[1];

function parseFeed(xml) {
  return (xml.match(/<(item|entry)[\s>][\s\S]*?<\/\1>/g) ?? []).map((entry) => {
    const link = text(tag(entry, "link")) || entry.match(/<link[^>]*href="([^"]+)"/)?.[1] || "";
    return { id: text(tag(entry, "guid") ?? tag(entry, "id")) || link, title: text(tag(entry, "title")), link, summary: text(tag(entry, "description") ?? tag(entry, "summary")) };
  });
}

text() löst CDATA auf, dekodiert Entities und entfernt HTML. Aus einer Zusammenfassung voller <p>-Tags wird so sauberer Text.

3. Eine Anfrage, überall die richtige Länge

X erlaubt 280 Zeichen, Bluesky 300, LinkedIn 3.000. Statt drei Anfragen schickst du einen Beitrag mit einem langen Standardtext und kurzen Fassungen für die strengen Netzwerke, über platformOptions:

json
{
  "content": "Title\n\nFirst 500 characters of the summary…\n\nhttps://blog.example.com/post",
  "platforms": ["x", "linkedin", "bluesky"],
  "platformOptions": {
    "x": { "content": "Title\n\nhttps://blog.example.com/post" },
    "bluesky": { "content": "Title\n\nhttps://blog.example.com/post" }
  }
}

Hapi prüft die Fassung jedes Netzwerks gegen dessen Limit, bevor etwas gepostet wird. So scheitert nichts auf halbem Weg.

4. Nie doppelt posten

Das Skript merkt sich die IDs, die es schon geteilt hat, in seen.json. Dateien gehen aber verloren, und Prozesse stürzen zwischen „gepostet“ und „gespeichert“ ab. Deshalb schickt jeder Beitrag zusätzlich einen Idempotency-Key, gebildet aus der ID des Feed-Eintrags. Wird derselbe Eintrag noch einmal gesendet, liefert Hapi den ursprünglichen Beitrag zurück, statt ein Duplikat zu veröffentlichen.

Beim allerersten Lauf merkt sich das Skript nur, was schon im Feed steht. Es flutet deine Konten nicht mit dem gesamten Archiv.

5. Das komplette Skript

js
// dlvrit-clone.mjs: share every new feed item to your social accounts, via Hapi.
// Keep it running:  HAPI_API_KEY=hapi_live_... FEED_URL=https://blog.example.com/feed node dlvrit-clone.mjs
// Or from cron:     ... node dlvrit-clone.mjs --once
import crypto from "node:crypto";
import fs from "node:fs";

const HAPI = process.env.HAPI_API_BASE ?? "https://hapi.tools/v1";
const { HAPI_API_KEY, FEED_URL } = process.env;
const PLATFORMS = (process.env.PLATFORMS ?? "x,linkedin,bluesky").split(",");
const EVERY_MINUTES = Number(process.env.EVERY_MINUTES ?? 10);
const SEEN_FILE = process.env.SEEN_FILE ?? "seen.json";

async function hapi(path, options = {}) {
  const res = await fetch(HAPI + path, {
    ...options,
    headers: { Authorization: `Bearer ${HAPI_API_KEY}`, "Content-Type": "application/json", ...options.headers },
  });
  const body = await res.json();
  if (!res.ok) throw new Error(body.error?.message ?? `Hapi returned ${res.status}`);
  return body;
}

// CDATA → entities → strip HTML → tidy whitespace.
const ENTITIES = { amp: "&", lt: "<", gt: ">", quot: '"', apos: "'", "#39": "'", nbsp: " " };
const text = (s = "") =>
  s
    .replace(/<!\[CDATA\[([\s\S]*?)\]\]>/g, "$1")
    .replace(/&(amp|lt|gt|quot|apos|#39|nbsp);/g, (_, e) => ENTITIES[e])
    .replace(/<[^>]+>/g, " ")
    .replace(/\s+/g, " ")
    .trim();

const tag = (xml, name) => xml.match(new RegExp(`<${name}[^>]*>([\\s\\S]*?)</${name}>`))?.[1];

function parseFeed(xml) {
  return (xml.match(/<(item|entry)[\s>][\s\S]*?<\/\1>/g) ?? []).map((entry) => {
    const link = text(tag(entry, "link")) || entry.match(/<link[^>]*href="([^"]+)"/)?.[1] || "";
    return { id: text(tag(entry, "guid") ?? tag(entry, "id")) || link, title: text(tag(entry, "title")), link, summary: text(tag(entry, "description") ?? tag(entry, "summary")) };
  });
}

function compose(item) {
  const short = (limit) => {
    const room = limit - item.link.length - 2;
    const title = item.title.length > room ? `${item.title.slice(0, room - 1)}…` : item.title;
    return `${title}\n\n${item.link}`;
  };
  const summary = item.summary.length > 500 ? `${item.summary.slice(0, 499)}…` : item.summary;
  return {
    content: [item.title, summary, item.link].filter(Boolean).join("\n\n"),
    platforms: PLATFORMS,
    platformOptions: { x: { content: short(280) }, bluesky: { content: short(300) } },
  };
}

async function check() {
  const seen = new Set(fs.existsSync(SEEN_FILE) ? JSON.parse(fs.readFileSync(SEEN_FILE, "utf8")) : []);
  const save = () => fs.writeFileSync(SEEN_FILE, JSON.stringify([...seen]));
  const res = await fetch(FEED_URL);
  if (!res.ok) throw new Error(`Feed returned ${res.status}`);
  const items = parseFeed(await res.text()).filter((item) => item.id && item.link);

  if (seen.size === 0) {
    // First run: remember the back catalog instead of posting all of it.
    items.forEach((item) => seen.add(item.id));
    save();
    console.log(`Watching ${FEED_URL}: ${items.length} existing posts remembered. New ones will be shared.`);
    return;
  }
  for (const item of items.filter((i) => !seen.has(i.id)).reverse()) {
    const key = `feed-${crypto.createHash("sha256").update(item.id).digest("hex").slice(0, 40)}`;
    await hapi("/posts", { method: "POST", headers: { "Idempotency-Key": key }, body: JSON.stringify(compose(item)) });
    seen.add(item.id);
    save();
    console.log(`Shared: ${item.title}`);
  }
}

if (process.argv.includes("--once")) {
  await check();
} else {
  const run = () => check().catch((err) => console.error(`Check failed: ${err.message}`));
  await run();
  setInterval(run, EVERY_MINUTES * 60_000);
}

So startest du es:

bash
HAPI_API_KEY=hapi_live_... FEED_URL=https://blog.example.com/feed node dlvrit-clone.mjs

Der erste Lauf merkt sich, was schon veröffentlicht ist. Veröffentlichst du einen neuen Beitrag, liegt er innerhalb von zehn Minuten auf X, LinkedIn und Bluesky. Sollen deine Community-Kanäle auch dabei sein:

bash
PLATFORMS=x,linkedin,bluesky,discord,slack,telegram node dlvrit-clone.mjs

Was Hapi für dich übernommen hat

  • OAuth und Token-Refresh für jedes Netzwerk
  • das Zeichenlimit jedes Netzwerks, geprüft, bevor irgendetwas gepostet wird
  • denselben Eintrag nie zweimal veröffentlichen (Idempotency-Key)
  • Wiederholungen, Zustellung und einen post.failed-Webhook, falls ein Netzwerk einen Beitrag ablehnt

Deine 90 Zeilen sind nur das Produkt: der Feed, was sich das Skript merkt, und wie der Beitrag klingt.

Daraus ein echtes Produkt machen

  • Viele Feeds: Geh eine Liste von FEED_URLs durch, jede mit eigenen PLATFORMS.
  • Besseres Timing: Statt sofort zu posten, schick scheduledAt für das nächste passende Zeitfenster (siehe die Warteschlange in unserem Buffer-Klon).
  • Bilder: Hol das og:image des Beitrags, lade es mit POST /v1/media hoch und häng mediaIds an.
  • KI-Texte: Lass Claude für jedes Netzwerk einen Aufmacher schreiben, statt nur den Titel zu übernehmen (siehe unseren Taplio-Klon).
  • Kunden: Mit Hapi Pro betreibst du einen Feed pro Kunde, mit einem API-Key, der auf das Profil dieses Kunden beschränkt ist.

Wer es nutzen kann

So wie es hier gebaut ist, postet der Klon auf die Konten in deinem Hapi-Workspace: deine eigene Marke, dein Team oder Kunden, die du betreust. Ihn als öffentliches SaaS anzubieten, bei dem jede Person die eigenen Konten verbindet, braucht eine Kontoverbindung über die API. Die bietet Hapi noch nicht.

Bau deinen: 15 Tage für 5 $ →

dlvr.it ist eine Marke des Rechteinhabers. Dieses Tutorial steht in keiner Verbindung zu dlvr.it und wird nicht von dlvr.it unterstützt. „dlvr.it-Klon“ beschreibt den Ablauf vom Feed auf Social Media, den es nachbaut.