Blog

· 5 min read

How to build a dlvr.it clone: auto-share every new blog post to X, LinkedIn and Bluesky

Build your own RSS-to-social auto-poster: watch any blog or podcast feed and share each new post to X, LinkedIn, Bluesky, Discord, Slack and Telegram, with the right length for each network. About 90 lines of Node, no dependencies.

dlvr.it does one job really well: you publish a blog post, and it shows up on your social accounts a few minutes later. No copy-pasting, no forgetting.

The job has two halves:

  • Watching a feed. Fetch the RSS, spot what's new, remember what you've already shared. That's about 60 lines.
  • Posting it everywhere. OAuth for every network, token refresh, a different length limit on each one, never posting the same thing twice. That's the infrastructure, and that's Hapi.

By the end you'll have a small script that:

  • Watches any RSS or Atom feed: your blog, a podcast, a YouTube channel, a changelog
  • Shares each new item to X, LinkedIn and Bluesky (plus Discord, Slack and Telegram if you like)
  • Writes a short version for X and Bluesky and a longer one with a summary for LinkedIn, in one request
  • Never double-posts, even if it crashes, restarts, or loses its memory file

It's one file, Node 22 or later, no dependencies, no database.

1. Set up Hapi

  1. Start a Hapi trial (15 days for $5).
  2. Connect your accounts under Accounts: X and LinkedIn in one click, Bluesky with an app password.
  3. Create an API key.

2. Reading a feed without a library

RSS and Atom are both "a list of entries with a title, a link and an ID". A few regular expressions cover the feeds you'll meet in practice (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() unwraps CDATA, decodes entities and strips HTML, so a summary full of <p> tags becomes clean text.

3. One request, the right length everywhere

X allows 280 characters, Bluesky 300, LinkedIn 3,000. Instead of three requests, send one post with a long default and short versions for the strict networks, using 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 checks each network's version against that network's limit before posting, so nothing fails halfway.

4. Never double-post

The script keeps the IDs it has shared in seen.json. But files get lost and processes crash between "posted" and "saved". So every post also sends an Idempotency-Key made from the feed item's ID: if the same item is ever sent again, Hapi returns the original post instead of publishing a duplicate.

And on the very first run, the script only remembers what's already in the feed. It won't flood your accounts with your whole back catalog.

5. The whole script

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);
}

Run it:

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

The first run remembers what's already published. Publish a new post, and within ten minutes it's on X, LinkedIn and Bluesky. To include your community channels too:

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

What Hapi handled for you

  • OAuth and token refresh for every network
  • Each network's character limit, checked before anything is posted
  • Never publishing the same item twice (Idempotency-Key)
  • Retries, delivery, and a post.failed webhook if a network rejects a post

Your 90 lines are just the product: the feed, the memory, and how the post reads.

Make it a real product

  • Many feeds: loop over a list of FEED_URLs, each with its own PLATFORMS.
  • Better timing: instead of posting instantly, send scheduledAt for the next good slot (see the queue in our Buffer clone).
  • Images: grab the post's og:image, upload it with POST /v1/media, and attach mediaIds.
  • AI captions: have Claude write a hook for each network instead of reusing the title (see our Taplio clone).
  • Clients: on Hapi Pro, run one feed per client with an API key restricted to that client's profile.

Who can use it

As built here, the clone posts to the accounts connected in your Hapi workspace: your own brand, your team, or clients you manage. Offering it as a public SaaS where anyone connects their own accounts needs account connection through the API, which Hapi doesn't offer yet.

Build yours: 15 days for $5 →

dlvr.it is a trademark of its owner. This tutorial isn't affiliated with or endorsed by dlvr.it; "dlvr.it clone" describes the feed-to-social workflow it recreates.