· 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
- Starte eine Hapi-Testphase (15 Tage für 5 $).
- Verbinde deine Konten unter Accounts: X und LinkedIn mit einem Klick, Bluesky mit einem App-Passwort.
- 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):
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:
{
"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
// 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:
HAPI_API_KEY=hapi_live_... FEED_URL=https://blog.example.com/feed node dlvrit-clone.mjsDer 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:
PLATFORMS=x,linkedin,bluesky,discord,slack,telegram node dlvrit-clone.mjsWas 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 eigenenPLATFORMS. - Besseres Timing: Statt sofort zu posten, schick
scheduledAtfür das nächste passende Zeitfenster (siehe die Warteschlange in unserem Buffer-Klon). - Bilder: Hol das
og:imagedes Beitrags, lade es mitPOST /v1/mediahoch und hängmediaIdsan. - 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.
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.