Blog

· 5 min leestijd

Zo bouw je een dlvr.it-kloon: deel elk nieuw blogbericht automatisch op X, LinkedIn en Bluesky

Bouw je eigen RSS-naar-social-poster: volg een blog- of podcastfeed en deel elk nieuw bericht op X, LinkedIn, Bluesky, Discord, Slack en Telegram, met de juiste lengte per netwerk. Ongeveer 90 regels Node, geen dependencies.

dlvr.it doet één ding heel goed: je publiceert een blogbericht, en een paar minuten later staat het op je social accounts. Geen kopiëren en plakken, niets vergeten.

Het werk heeft twee helften:

  • Een feed volgen. De RSS ophalen, zien wat nieuw is, onthouden wat je al hebt gedeeld. Dat is zo'n 60 regels.
  • Het overal posten. OAuth voor elk netwerk, tokens vernieuwen, een andere lengtelimiet per netwerk, nooit hetzelfde ding twee keer posten. Dat is de infrastructuur, en dat is Hapi.

Aan het eind heb je een klein script dat:

  • Elke RSS- of Atom-feed volgt: je blog, een podcast, een YouTube-kanaal, een changelog
  • Elk nieuw item deelt op X, LinkedIn en Bluesky (plus Discord, Slack en Telegram als je wilt)
  • In één request een korte versie schrijft voor X en Bluesky en een langere met een samenvatting voor LinkedIn
  • Nooit dubbel post, ook niet als het crasht, herstart of zijn geheugenbestand kwijtraakt

Het is één bestand, Node 22 of nieuwer, geen dependencies, geen database.

1. Hapi klaarzetten

  1. Start een Hapi-proefperiode (15 dagen voor $5).
  2. Koppel je accounts onder Accounts: X en LinkedIn in één klik, Bluesky met een app-wachtwoord.
  3. Maak een API-key aan.

2. Een feed lezen zonder bibliotheek

RSS en Atom zijn allebei "een lijst van items met een titel, een link en een ID". Een paar reguliere expressies dekken de feeds die je in de praktijk tegenkomt (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() pakt CDATA uit, decodeert entities en haalt HTML weg, zodat een samenvatting vol <p>-tags schone tekst wordt.

3. Eén request, overal de juiste lengte

X staat 280 tekens toe, Bluesky 300, LinkedIn 3.000. In plaats van drie requests stuur je één bericht met een lange standaardtekst en korte versies voor de strikte netwerken, via platformOptions:

json
{
  "content": "Titel\n\nEerste 500 tekens van de samenvatting…\n\nhttps://blog.example.com/post",
  "platforms": ["x", "linkedin", "bluesky"],
  "platformOptions": {
    "x": { "content": "Titel\n\nhttps://blog.example.com/post" },
    "bluesky": { "content": "Titel\n\nhttps://blog.example.com/post" }
  }
}

Hapi toetst de versie van elk netwerk aan de limiet van dat netwerk voordat er gepost wordt, zodat er niets halverwege misgaat.

4. Nooit dubbel posten

Het script bewaart de ID's die het heeft gedeeld in seen.json. Maar bestanden raken kwijt en processen crashen tussen "gepost" en "opgeslagen". Daarom stuurt elk bericht ook een Idempotency-Key mee, gemaakt van de ID van het feed-item: wordt hetzelfde item ooit opnieuw gestuurd, dan geeft Hapi het oorspronkelijke bericht terug in plaats van een duplicaat te publiceren.

En bij de allereerste run onthoudt het script alleen wat er al in de feed staat. Het overspoelt je accounts niet met je hele archief.

5. Het hele script

js
// dlvrit-clone.mjs: deel elk nieuw feed-item op je social accounts, via Hapi.
// Laat het draaien:  HAPI_API_KEY=hapi_live_... FEED_URL=https://blog.example.com/feed node dlvrit-clone.mjs
// Of via 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 antwoordde met ${res.status}`);
  return body;
}

// CDATA → entities → HTML eruit → witruimte opruimen.
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 antwoordde met ${res.status}`);
  const items = parseFeed(await res.text()).filter((item) => item.id && item.link);

  if (seen.size === 0) {
    // Eerste run: onthoud het bestaande archief in plaats van alles te posten.
    items.forEach((item) => seen.add(item.id));
    save();
    console.log(`Volg ${FEED_URL}: ${items.length} bestaande berichten onthouden. Nieuwe worden gedeeld.`);
    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(`Gedeeld: ${item.title}`);
  }
}

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

Starten:

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

De eerste run onthoudt wat er al gepubliceerd is. Publiceer een nieuw bericht, en binnen tien minuten staat het op X, LinkedIn en Bluesky. Om je communitykanalen erbij te doen:

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

Wat Hapi voor je deed

  • OAuth en token-refresh voor elk netwerk
  • De tekenlimiet van elk netwerk, gecontroleerd voordat er iets wordt gepost
  • Nooit hetzelfde item twee keer publiceren (Idempotency-Key)
  • Retries, aflevering, en een post.failed-webhook als een netwerk een bericht weigert

Je 90 regels zijn alleen het product: de feed, het geheugen en hoe het bericht leest.

Maak er een echt product van

  • Meerdere feeds: loop over een lijst FEED_URLs, elk met eigen PLATFORMS.
  • Betere timing: post niet meteen, maar stuur scheduledAt voor het eerstvolgende goede moment (zie de wachtrij in onze Buffer-kloon).
  • Afbeeldingen: pak de og:image van het bericht, upload die met POST /v1/media en hang mediaIds eraan.
  • AI-bijschriften: laat Claude een hook schrijven per netwerk in plaats van de titel te hergebruiken (zie onze Taplio-kloon).
  • Klanten: draai op Hapi Pro één feed per klant, met een API-key die beperkt is tot het profiel van die klant.

Wie het kan gebruiken

Zoals het hier staat, post de kloon naar de accounts die in jouw Hapi-workspace gekoppeld zijn: je eigen merk, je team, of klanten die je beheert. Het aanbieden als openbare SaaS, waar iedereen zijn eigen accounts koppelt, vraagt om accounts koppelen via de API. Dat biedt Hapi nog niet.

Bouw de jouwe: 15 dagen voor $5 →

dlvr.it is een handelsmerk van de eigenaar. Deze tutorial is niet gelieerd aan dlvr.it en wordt er niet door ondersteund; "dlvr.it-kloon" beschrijft de feed-naar-social-workflow die hij nabouwt.